├── .gitignore ├── .htaccess ├── LICENCE.md ├── README.md ├── config-dist.php ├── css ├── microblog │ ├── icons │ │ ├── icon-announce.svg │ │ └── icon-like.svg │ └── microblog.css ├── plain │ ├── icons │ │ ├── icon-announce.svg │ │ └── icon-like.svg │ ├── plain.css │ └── plain.js ├── reset.css ├── solarized-dark │ ├── icons │ │ ├── icon-announce.svg │ │ └── icon-like.svg │ └── solarized-dark.css └── solarized-light │ ├── icons │ ├── icon-announce.svg │ └── icon-like.svg │ └── solarized-light.css ├── favicon-large.png ├── favicon.ico ├── index.php ├── js ├── microblog.js ├── passkeys.js └── squircle.js ├── lib ├── activitypub-actor.php ├── activitypub-followers.php ├── activitypub-functions.php ├── activitypub-inbox.php ├── activitypub-outbox.php ├── activitypub-webfinger.php ├── atprotocol.php ├── bar.php ├── database.php ├── functions.php ├── passkeys.php ├── password-dict.txt ├── rsd.xml.php ├── twitter_api.php └── xmlrpc.php ├── snippets ├── footer.snippet.php ├── header.snippet.php └── nav.snippet.php └── templates ├── loginform.inc.php ├── postform.inc.php ├── settings.inc.php ├── single.inc.php └── timeline.inc.php /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | config.php 3 | feed.* 4 | feeds/ 5 | files/ 6 | test.php 7 | log.txt 8 | .ddev/ 9 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | AddCharset UTF-8 .xml 2 | AddCharset UTF-8 .json 3 | 4 | AddType application/atom+xml .xml 5 | AddType application/json .json 6 | 7 | 8 | 9 | Order allow,deny 10 | Deny from all 11 | 12 | 13 | Require all denied 14 | 15 | 16 | 17 | 18 | RewriteEngine On 19 | RewriteBase / 20 | 21 | # friendly URLs 22 | RewriteRule ^feed/json/?$ feed/feed.json [L] 23 | RewriteRule ^feed/atom/?$ feed/feed.xml [L] 24 | 25 | RewriteRule ^.well_known/webfinger$ /.well-known/webfinger [R=302] # stupid 26 | RewriteRule ^.well-known/webfinger$ /webfinger [L] # correct 27 | 28 | RewriteCond %{REQUEST_FILENAME} !-f 29 | RewriteCond %{REQUEST_FILENAME} !-d 30 | RewriteRule ^(.*) index.php [L] 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Arno Richter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mini Microblog 2 | 3 | A simple PHP app that stores Twitter-like status updates in a sqlite database. It also generates a JSON feed, that can be used as a source for the [Micro.Blog](https://micro.blog/) service. It is aimed at people who would like to host their own micro.blog, but want to avoid using Wordpress for it. 4 | 5 | The app supports crossposting to Bluesky and also acts as a member of the Fediverse, in that eg. Mastodon/ActivityPub users can follow and like status updates. 6 | 7 |  8 | 9 | There is a timeline view of your own posts, as well as a simple 'compose post' page behind a login form. Right now, only a unique ID, the post content and creation timestamp, edit time and delete status are saved for each entry, so this is only suitable for one user. 10 | 11 | The entire design is in a directory inside a `/css/` (eg. [microblog/microblog.css](css/microblog/microblog.css)) and can be modified easily. The site HTML is pretty straightforward and should be easy to style from the `/templates/` and `/snippets/` directories. 12 | 13 | ATOM and JSON feeds are provided and rerendered as static files when posting. 14 | 15 | If the PHP version on the server supports it, an XML-RPC interface is provided to enable posting from external apps, such as [Marsedit](https://redsweater.com/marsedit/). Please set an `app_token` in [config.php](config-dist.php#L28) as secret to use with your username. If you don't set one, you have to use your login password to authenticate. You can use the metaWeblog API, that is discovered automatically, or add a Micro.Blog account and point it to your site. As a bonus, you can schedule posts this way, if you set the creation date in the future ;) 16 | 17 | The app requires at least PHP 7.2 and was tested on 8.1. It needs mbstring, curl, sqlite and openssl modules. 18 | 19 | ### Installation 20 | 21 | - copy (or clone) the files to a directory on your webserver 22 | - have them accessible from the domain (or subdomain) root `/` 23 | - for Apache: edit [.htaccess](.htaccess) and set `RewriteBase` to a path matching your installation directory 24 | - for nginx: have a rule similar to `try_files $uri $uri/ /index.php?$args;` for the microblog-location 25 | - open the website in the browser, it should take you to the site settings page and prompt you to enter a password, or set up a passkey 26 | 27 | ### Optional 28 | 29 | - modify the theme file [microblog.css](css/microblog/microblog.css) 30 | - do more configuration under `/settings`, eg. fill in ActivityPub and Bluesky info 31 | - set an `app_token` to use with XML-RPC 32 | - setup a Passkey to log in with 33 | 34 | ### To Do 35 | 36 | - test whether the [ping function](http://help.micro.blog/2017/api-feeds/) actually works 37 | - improve html rendering (?) 38 | - unify autolink regexes of `atprotocol.php` and `autolink.php` 39 | - support file attachments (started! can attach images to posts and remove them) 40 | - see issues 41 | 42 | ### Support my work 43 | 44 | The app is provided for free, but if you'd like to support what I do, please consider tipping or sponsoring – it is greatly appreciated. Can't promise it'll buy you software support, but if you send a reasonable PR, I'm happy to accept improvements to the app. Links are under GitHub's official sponsor button. 45 | -------------------------------------------------------------------------------- /config-dist.php: -------------------------------------------------------------------------------- 1 | "); 19 | DEFINE('NOW', time()); 20 | 21 | // set up database connection 22 | require_once(ROOT.DS.'lib'.DS.'database.php'); 23 | 24 | /* make the path easier to read */ 25 | $dir = dirname($_SERVER['SCRIPT_NAME']); 26 | $uri = $_SERVER['REQUEST_URI']; 27 | $uri = substr($uri, mb_strlen($dir)); // handle subdir installs 28 | $path_fragments = parse_url($uri, PHP_URL_PATH); 29 | $path = (empty($path_fragments)) ? [''] : explode('/', trim($path_fragments, '/')); 30 | if(mb_strlen($path[0]) == 0) { $path = []; } 31 | 32 | // load settings 33 | $statement = $db->prepare('SELECT * FROM settings'); 34 | $statement->execute(); 35 | $settings_raw = $statement->fetchAll(PDO::FETCH_ASSOC); 36 | 37 | $default_settings = array( 38 | 'url' => '', 39 | 'path' => __DIR__, 40 | 'language' => 'en', 41 | 'max_characters' => 280, 42 | 'posts_per_page' => 10, 43 | 'theme' => 'plain', 44 | 'microblog_account' => '', 45 | 'site_title' => 'Another Microblog', 46 | 'site_claim' => 'This is an automated account. Don\'t mention or reply please.', 47 | 'admin_user' => 'admin', 48 | 'admin_pass' => '', 49 | 'app_token' => '', 50 | 'cookie_life' => 60*60*24*7*4, 51 | 'ping' => true, 52 | 'activitypub' => true, 53 | 'show_edits' => true, 54 | 'local_timezone' => 'Europe/Berlin' 55 | ); 56 | 57 | if(!empty($settings_raw)) { 58 | // create config array 59 | $settings = array_column($settings_raw, 'settings_value', 'settings_key'); 60 | } else { 61 | // there were no settings in the DB. initialize! 62 | $settings = []; 63 | 64 | $old_config = $config ?? []; 65 | $settings = array_merge($default_settings, $old_config); // respect existing config file 66 | } 67 | 68 | $config = array_merge($default_settings, $settings); // handle fresh install case where $settings is mostly empty 69 | $settings = $config; 70 | 71 | date_default_timezone_set($config['local_timezone']); 72 | $config['path'] = $path; 73 | $config['url_detected'] = 'http'.(!empty($_SERVER['HTTPS']) ? 's' : '').'://'.$_SERVER['HTTP_HOST'].rtrim($dir, '/'); 74 | $config['subdir_install'] = ($dir === '/') ? false : true; 75 | $config['xmlrpc'] = function_exists('xmlrpc_server_create'); 76 | $config['local_time_offset'] = date('P'); 77 | 78 | unset($dir, $uri, $path_fragments, $path); 79 | 80 | // load functions 81 | require_once(ROOT.DS.'lib'.DS.'functions.php'); 82 | -------------------------------------------------------------------------------- /css/microblog/icons/icon-announce.svg: -------------------------------------------------------------------------------- 1 | 2 | Repost 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/microblog/icons/icon-like.svg: -------------------------------------------------------------------------------- 1 | 2 | Like 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/microblog/microblog.css: -------------------------------------------------------------------------------- 1 | @import '../reset.css'; 2 | 3 | :root { 4 | --primary-color: #007aff; 5 | --secondary-color: #fffceb; 6 | 7 | --background-color: #b5b5af; 8 | --text-color: #080f15; 9 | } 10 | 11 | html { 12 | font: 100%/1.4 system-ui, Helvetica, sans-serif; 13 | background-color: var(--background-color); 14 | color: var(--text-color); 15 | } 16 | 17 | img { 18 | display: block; 19 | max-width: 100%; 20 | height: auto; 21 | } 22 | 23 | .wrap { 24 | width: min(95%, 40rem); 25 | margin: 2rem auto; 26 | padding: 1rem; 27 | background-color: var(--secondary-color); 28 | box-shadow: 0 1.25rem 1rem -1rem rgba(0,0,0,0.25); 29 | } 30 | 31 | .button { 32 | display: block; 33 | background: var(--primary-color); 34 | color: var(--secondary-color); 35 | text-decoration: none; 36 | border-radius: 0.4rem; 37 | padding: 0.2rem 0.5rem; 38 | font-weight: bold; 39 | text-align: center; 40 | } 41 | 42 | .button.alert { 43 | background: coral; 44 | } 45 | 46 | .hidden { 47 | display: none !important; 48 | } 49 | 50 | nav.main ul { 51 | display: flex; 52 | margin-bottom: 2rem; 53 | } 54 | 55 | nav.main li { 56 | list-style: none; 57 | flex: 1; 58 | } 59 | 60 | nav.main li a { 61 | /* inherits from .button */ 62 | } 63 | 64 | nav.main li + li a { 65 | margin-left: 0.2rem; 66 | } 67 | 68 | .wrap .posts { 69 | 70 | } 71 | 72 | .wrap .posts > li { 73 | list-style: none; 74 | margin-bottom: 1rem; 75 | border-bottom: 1px solid rgba(0,0,0,0.1); 76 | padding-bottom: 1rem; 77 | 78 | display: grid; 79 | grid-template-columns: repeat(6, 1fr); 80 | } 81 | 82 | .wrap .posts > li > * { 83 | outline: 0px solid #f0f; 84 | } 85 | 86 | .wrap .posts > li:last-child { 87 | margin-bottom: 2rem; 88 | border-bottom: 0; 89 | padding-bottom: 0; 90 | } 91 | 92 | .timeline .pagination { overflow: hidden; } 93 | .timeline .pagination a { 94 | display: block; 95 | background: var(--primary-color); 96 | color: var(--secondary-color); 97 | text-decoration: none; 98 | border-radius: 0.4rem; 99 | padding: 0.2rem 0.6rem; 100 | font-weight: bold; 101 | float: left; 102 | } 103 | .timeline .pagination .next { float: right; } 104 | 105 | .wrap .post-timestamp { 106 | display: block; 107 | color: var(--primary-color); 108 | text-decoration: none; 109 | font-size: 0.8rem; 110 | text-transform: uppercase; 111 | font-weight: bold; 112 | margin-bottom: 0.5rem; 113 | grid-column-start: span 3; 114 | } 115 | 116 | .wrap .post-timestamp time.modified { 117 | display: block; 118 | color: hsla(0, 0%, 0%, 0.2); 119 | mix-blend-mode: multiply; 120 | } 121 | 122 | .wrap .post-meta { 123 | grid-column-start: span 3; 124 | } 125 | 126 | .wrap .post-meta ul { 127 | display: flex; 128 | justify-content: flex-end; 129 | gap: 0.75ch; 130 | } 131 | 132 | .wrap .post-meta li { 133 | list-style: none; 134 | } 135 | 136 | .wrap .post-meta li a { 137 | display: block; 138 | color: hsla(0, 0%, 0%, 0.2); 139 | mix-blend-mode: multiply; 140 | text-decoration: none; 141 | font-size: 0.8rem; 142 | text-transform: uppercase; 143 | font-weight: bold; 144 | margin-bottom: 0.5rem; 145 | } 146 | 147 | .wrap .post-meta li a:is(:hover, :focus) { 148 | color: currentColor; 149 | } 150 | 151 | .wrap .post-meta li span.amount { 152 | margin-inline-end: 0.25ch; 153 | } 154 | 155 | .wrap .post-meta li span.word { display: inline-block; text-indent: -9999px; } 156 | .wrap .post-meta li span.amount::after { 157 | display: inline-block; 158 | content: ''; 159 | background-repeat: no-repeat; 160 | background-position: center center; 161 | background-size: contain; 162 | vertical-align: middle; 163 | margin-inline-start: 0.25ch; 164 | opacity: 0.25; 165 | } 166 | 167 | .wrap .post-meta li a:is(:hover, :focus) span.amount::after { 168 | opacity: 1; 169 | } 170 | 171 | .wrap .post-meta li.post-likes span.amount::after { 172 | background-image: url(./icons/icon-like.svg); 173 | width: 0.7rem; 174 | height: 0.7rem; 175 | } 176 | 177 | .wrap .post-meta li.post-boosts span.amount::after { 178 | background-image: url(./icons/icon-announce.svg); 179 | width: 1rem; 180 | height: 1rem; 181 | } 182 | 183 | .wrap .post-content { 184 | font-size: 1.25rem; 185 | overflow-wrap: break-word; 186 | grid-column-start: span 6; 187 | } 188 | 189 | .wrap .post-content a { 190 | color: var(--primary-color); 191 | text-decoration: none; 192 | } 193 | 194 | .wrap form.delete { 195 | width: 100%; 196 | grid-column-start: span 6; 197 | display: flex; 198 | margin-block-start: 2rem; 199 | } 200 | 201 | .wrap form.delete input[type="submit"] { 202 | flex: 1; 203 | line-height: 1.4; 204 | cursor: pointer; 205 | } 206 | 207 | .wrap .posts li .message { 208 | width: 100%; 209 | grid-column-start: span 6; 210 | margin-block-start: 2rem; 211 | } 212 | 213 | .postform form, 214 | form.edit, 215 | .login form { 216 | grid-column-start: span 6; 217 | overflow: hidden; 218 | } 219 | 220 | :is(.postform, .edit) textarea { 221 | width: 100%; 222 | border: 2px solid var(--background-color); 223 | background: #fff; 224 | padding: 0.5rem; 225 | font-size: 1.25rem; 226 | resize: vertical; 227 | min-height: 10rem; 228 | margin-bottom: 0.5rem; 229 | } 230 | 231 | :is(.postform, .edit) textarea:focus { 232 | border-color: var(--primary-color); 233 | outline: none; 234 | } 235 | 236 | :is(.postform, .edit) .post-nav { 237 | width: 100%; 238 | display: flex; 239 | gap: 1rem; 240 | align-items: center; 241 | } 242 | 243 | :is(.postform, .edit) input[type="submit"], 244 | .login input[type="submit"] { 245 | -webkit-appearance: none; 246 | appearance: none; 247 | border: 0; 248 | display: block; 249 | background: var(--primary-color); 250 | color: var(--secondary-color); 251 | text-decoration: none; 252 | border-radius: 0.4rem; 253 | padding: 0.3rem 0.8rem 0.4rem; 254 | font-weight: bold; 255 | text-align: center; 256 | cursor: pointer; 257 | float: right; 258 | } 259 | 260 | :is(.postform, .edit) #count { 261 | color: var(--background-color); 262 | } 263 | 264 | :is(.postform, .edit) #post-droparea { 265 | border: 0.15rem dashed rgba(0,0,0,0.2); 266 | color: rgba(0,0,0,0.35); 267 | padding: 0.25rem; 268 | cursor: pointer; 269 | } 270 | 271 | :is(.postform, .edit) #post-droparea.drag, 272 | :is(.postform, .edit) #post-droparea:is(:hover, :focus) { 273 | background-color: var(--primary-color); 274 | color: #fff; 275 | border: 0.15rem solid var(--primary-color); 276 | } 277 | 278 | :is(.postform, .edit) #post-attachments-label { 279 | display: flex; 280 | border: 0.15rem dashed rgba(0,0,0,0.4); 281 | color: rgba(0,0,0,0.4); 282 | padding: 0.25rem; 283 | cursor: pointer; 284 | position: relative; 285 | align-self: stretch; 286 | align-items: center; 287 | } 288 | 289 | :is(.postform, .edit) #post-attachments { 290 | /* cover the entire label, for drag and drop */ 291 | display: block; 292 | position: absolute; 293 | top: 0; 294 | left: 0; 295 | width: 100%; 296 | height: 100%; 297 | opacity: 0; 298 | } 299 | 300 | :is(.postform, .edit) #post-attachments-list { 301 | flex: 1; 302 | display: flex; 303 | flex-direction: column; 304 | flex-wrap: nowrap; 305 | padding-inline-end: 1rem; 306 | align-self: stretch; 307 | justify-content: center; 308 | } 309 | 310 | :is(.postform, .edit) #post-attachments-list li + li { 311 | margin-block-start: 0.25em; 312 | border-top: 1px solid rgba(0,0,0,0.2); 313 | padding-block-start: 0.25em; 314 | } 315 | 316 | :is(.postform, .edit) #post-attachments-list img.file-preview { 317 | display: inline-block; 318 | vertical-align: middle; 319 | margin-right: 1ch; 320 | width: 1.75rem; 321 | height: 1.75rem; 322 | outline: 0px solid #f0f; 323 | object-fit: cover; 324 | background: #fff; 325 | } 326 | 327 | :is(.postform, .edit) #post-attachments-list input[type="checkbox"] { 328 | -webkit-appearance: checkbox; 329 | appearance: checkbox; 330 | } 331 | 332 | :is(.timeline, .single) .post-attachments { 333 | grid-column-start: span 6; 334 | margin-block-start: 1rem; 335 | } 336 | 337 | :is(.timeline, .single) .post-attachments li + li { 338 | margin-block-start: 0.5rem; 339 | } 340 | 341 | :is(.postform, .edit) .message, 342 | .login .message { 343 | background-color: #87b26c; 344 | padding: 0.5rem; 345 | color: var(--secondary-color); 346 | border-radius: 0.4rem; 347 | margin-bottom: 0.5rem; 348 | } 349 | 350 | :is(.postform, .edit) .error, 351 | .login .error { 352 | background-color: #9c2128; 353 | } 354 | 355 | .login form { 356 | margin-top: 0.75rem; 357 | } 358 | 359 | .login input[type="text"], 360 | .login input[type="password"] { 361 | width: 100%; 362 | border: 2px solid var(--background-color); 363 | background: #fff; 364 | padding: 0.5rem; 365 | font-size: 1.25rem; 366 | resize: vertical; 367 | margin-bottom: 0.5rem; 368 | } 369 | 370 | 371 | .login .login-nav { 372 | display: flex; 373 | gap: 1rem; 374 | flex-direction: row-reverse; 375 | justify-content: end; 376 | align-items: center; 377 | } 378 | 379 | .login input[type="submit"] { 380 | float: none; 381 | } 382 | 383 | .login input[type="text"]:focus, 384 | .login input[type="password"]:focus { 385 | border-color: var(--primary-color); 386 | outline: none; 387 | } 388 | 389 | .settings .post-nav { 390 | display: flex; 391 | justify-content: flex-end; 392 | } 393 | 394 | .settings fieldset { 395 | margin-block: 1rem 2rem; 396 | } 397 | 398 | .settings fieldset legend { 399 | text-transform: uppercase; 400 | font-weight: 700; 401 | font-size: 85%; 402 | margin-block-end: 1rem; 403 | } 404 | 405 | .settings fieldset dl { 406 | display: grid; 407 | grid-template-columns: 2fr 3fr; 408 | grid-gap: 1.25rem 0; 409 | } 410 | 411 | .settings fieldset :is(dt, dd) { 412 | padding: 0.25em 0.5rem 0.25em 0; 413 | border-bottom: 0px solid CanvasText; 414 | outline: 0px solid red; 415 | } 416 | 417 | .settings fieldset dt label { 418 | font-size: 85%; 419 | } 420 | 421 | .settings fieldset dd :is(select, input[type="radio"], input[type="checkbox"]) { 422 | all: revert; 423 | font-size: 1rem; 424 | } 425 | 426 | .settings fieldset dd :is(input[type="text"], textarea) { 427 | width: 100%; 428 | padding: 0.25em 0.5em; 429 | border-bottom: 1px solid #aaa; 430 | } 431 | 432 | .settings fieldset dd :is(input, textarea)::placeholder { 433 | opacity: 0.4; 434 | } 435 | 436 | footer { 437 | width: min(95%, 40rem); 438 | margin: 0.5rem auto 2rem; 439 | } 440 | 441 | footer ul { 442 | list-style: none; 443 | display: flex; 444 | justify-content: center; 445 | gap: 1rem; 446 | } 447 | 448 | footer li a { 449 | color: hsla(0, 0%, 0%, 0.3); 450 | text-decoration: none; 451 | font-size: 0.8rem; 452 | text-transform: uppercase; 453 | font-weight: bold; 454 | margin-bottom: 0.5rem; 455 | } 456 | 457 | /* 458 | @supports (background: paint(id)) { 459 | input[type="submit"] { 460 | background: paint(squircle) !important; 461 | --squircle-radius: 8px; 462 | --squircle-fill: var(--primary-color); 463 | 464 | border-radius: 0; 465 | } 466 | } 467 | */ 468 | -------------------------------------------------------------------------------- /css/plain/icons/icon-announce.svg: -------------------------------------------------------------------------------- 1 | 2 | Repost 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/plain/icons/icon-like.svg: -------------------------------------------------------------------------------- 1 | 2 | Like 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/plain/plain.css: -------------------------------------------------------------------------------- 1 | @import '../reset.css'; 2 | 3 | /* 4 | // SYSTEM FONTS 5 | 6 | .body { font: -apple-system-body } 7 | .headline { font: -apple-system-headline } 8 | .subheadline { font: -apple-system-subheadline } 9 | .caption1 { font: -apple-system-caption1 } 10 | .caption2 { font: -apple-system-caption2 } 11 | .footnote { font: -apple-system-footnote } 12 | .short-body { font: -apple-system-short-body } 13 | .short-headline { font: -apple-system-short-headline } 14 | .short-subheadline { font: -apple-system-short-subheadline } 15 | .short-caption1 { font: -apple-system-short-caption1 } 16 | .short-footnote { font: -apple-system-short-footnote } 17 | .tall-body { font: -apple-system-tall-body } 18 | 19 | 20 | // SYSTEM COLORS https://blog.jim-nielsen.com/2021/css-system-colors/ 21 | 22 | AccentColor // Background of accented user interface controls 23 | AccentColorText // Text of accented user interface controls 24 | ActiveText // Text of active links 25 | ButtonBorder // Base border color of controls 26 | ButtonFace // Background color of controls 27 | ButtonText // Text color of controls 28 | Canvas // Background of application content or documents 29 | CanvasText // Text color in application content or documents 30 | Field // Background of input fields 31 | FieldText // Text in input fields 32 | GrayText // Text color for disabled items (e.g. a disabled control) 33 | Highlight // Background of selected items 34 | HighlightText // Text color of selected items 35 | LinkText // Text of non-active, non-visited links 36 | Mark // Background of text that has been specially marked (such as by the HTML mark element) 37 | MarkText // Text that has been specially marked (such as by the HTML mark element) 38 | VisitedText // Text of visited links 39 | */ 40 | 41 | :root { 42 | color-scheme: light dark; 43 | 44 | --primary-color: CanvasText; 45 | --secondary-color: ButtonText; 46 | 47 | --background-color: Canvas; 48 | --text-color: CanvasText; 49 | } 50 | 51 | html { 52 | font: 100%/1.4 system-ui, Helvetica, sans-serif; 53 | background-color: var(--background-color); 54 | color: var(--text-color); 55 | 56 | accent-color: var(--primary-color); 57 | -webkit-user-select: none; 58 | user-select: none; 59 | -webkit-tap-highlight-color: transparent; 60 | tap-highlight-color: transparent; 61 | -webkit-touch-callout: none; 62 | touch-callout: none; 63 | } 64 | 65 | img { 66 | display: block; 67 | max-width: 100%; 68 | height: auto; 69 | } 70 | 71 | .wrap { 72 | width: min(95%, 40rem); 73 | margin: 2rem auto; 74 | padding: 1rem; 75 | } 76 | 77 | .button { 78 | display: block; 79 | background: var(--primary-color); 80 | color: var(--background-color); 81 | text-decoration: none; 82 | border-radius: 0.4rem; 83 | padding: 0.2rem 0.5rem; 84 | font-weight: bold; 85 | text-align: center; 86 | } 87 | 88 | .button.alert { 89 | background: coral; 90 | } 91 | 92 | .hidden { 93 | display: none !important; 94 | } 95 | 96 | nav.main ul { 97 | display: flex; 98 | margin-block-end: 2rem; 99 | } 100 | 101 | nav.main li { 102 | list-style: none; 103 | flex: 1; 104 | } 105 | 106 | nav.main li a { 107 | /* inherits from .button */ 108 | } 109 | 110 | nav.main li + li a { 111 | margin-inline-start: 0.2rem; 112 | } 113 | 114 | .wrap .posts { 115 | 116 | } 117 | 118 | .wrap .posts > li { 119 | list-style: none; 120 | margin-block-end: 1rem; 121 | border-bottom: 1px solid CanvasText; 122 | padding-block-end: 1rem; 123 | 124 | display: grid; 125 | grid-template-columns: repeat(6, 1fr); 126 | } 127 | 128 | .wrap .posts > li > * { 129 | outline: 0px solid #f0f; 130 | } 131 | 132 | .wrap .posts > li:last-child { 133 | margin-block-end: 2rem; 134 | border-bottom: 0; 135 | padding-block-end: 0; 136 | } 137 | 138 | .timeline .pagination { overflow: hidden; } 139 | .timeline .pagination a { 140 | display: block; 141 | background: var(--primary-color); 142 | color: Canvas; 143 | text-decoration: none; 144 | border-radius: 0.4rem; 145 | padding: 0.2rem 0.6rem; 146 | font-weight: bold; 147 | float: left; 148 | } 149 | .timeline .pagination .next { float: right; } 150 | 151 | .wrap .post-timestamp { 152 | display: block; 153 | color: CanvasText; 154 | text-decoration: none; 155 | font-size: 0.8rem; 156 | text-transform: uppercase; 157 | font-weight: bold; 158 | margin-block-end: 0.5rem; 159 | grid-column-start: span 3; 160 | } 161 | 162 | .wrap .post-timestamp time.modified { 163 | display: block; 164 | /* color: hsla(0, 0%, 0%, 0.2); */ 165 | /* mix-blend-mode: multiply; */ 166 | color: color-mix(in oklch, CanvasText 20%, Canvas); 167 | } 168 | 169 | .wrap .post-meta { 170 | grid-column-start: span 3; 171 | } 172 | 173 | .wrap .post-meta ul { 174 | display: flex; 175 | justify-content: flex-end; 176 | gap: 0.75ch; 177 | } 178 | 179 | .wrap .post-meta li { 180 | list-style: none; 181 | } 182 | 183 | .wrap .post-meta li a { 184 | display: block; 185 | color: hsla(0, 0%, 0%, 0.2); 186 | color: color-mix(in oklch, CanvasText 20%, Canvas); 187 | /* mix-blend-mode: multiply; */ 188 | text-decoration: none; 189 | font-size: 0.8rem; 190 | text-transform: uppercase; 191 | font-weight: bold; 192 | margin-block-end: 0.5rem; 193 | } 194 | 195 | .wrap .post-meta li a:is(:hover, :focus) { 196 | color: currentColor; 197 | } 198 | 199 | .wrap .post-meta li span.amount { 200 | margin-inline-end: 0.25ch; 201 | } 202 | 203 | .wrap .post-meta li span.word { display: inline-block; text-indent: -9999px; } 204 | .wrap .post-meta li span.amount::after { 205 | display: inline-block; 206 | content: ''; 207 | background-repeat: no-repeat; 208 | background-position: center center; 209 | background-size: contain; 210 | vertical-align: middle; 211 | margin-inline-start: 0.25ch; 212 | opacity: 0.25; 213 | } 214 | 215 | .wrap .post-meta li a:is(:hover, :focus) span.amount::after { 216 | opacity: 1; 217 | } 218 | 219 | .wrap .post-meta li.post-likes span.amount::after { 220 | background-image: url(./icons/icon-like.svg); 221 | width: 0.7rem; 222 | height: 0.7rem; 223 | } 224 | 225 | .wrap .post-meta li.post-boosts span.amount::after { 226 | background-image: url(./icons/icon-announce.svg); 227 | width: 1rem; 228 | height: 1rem; 229 | } 230 | 231 | .wrap .post-content { 232 | font-size: 1.25rem; 233 | overflow-wrap: break-word; 234 | grid-column-start: span 6; 235 | } 236 | 237 | .wrap .post-content a { 238 | color: var(--primary-color); 239 | text-decoration: none; 240 | } 241 | 242 | .wrap form.delete { 243 | width: 100%; 244 | grid-column-start: span 6; 245 | display: flex; 246 | margin-block-start: 2rem; 247 | } 248 | 249 | .wrap form.delete input[type="submit"] { 250 | flex: 1; 251 | line-height: 1.4; 252 | cursor: pointer; 253 | } 254 | 255 | .wrap .posts li .message { 256 | width: 100%; 257 | grid-column-start: span 6; 258 | margin-block-start: 2rem; 259 | } 260 | 261 | .postform form, 262 | form.edit, 263 | .login form { 264 | grid-column-start: span 6; 265 | overflow: hidden; 266 | } 267 | 268 | :is(.postform, .edit) textarea { 269 | width: 100%; 270 | border: 2px solid CanvasText; 271 | background: Canvas; 272 | padding: 0.5rem; 273 | font-size: 1.25rem; 274 | resize: vertical; 275 | min-height: 10rem; 276 | margin-block-end: 0.5rem; 277 | } 278 | 279 | :is(.postform, .edit) textarea:focus { 280 | border-color: var(--primary-color); 281 | outline: none; 282 | } 283 | 284 | :is(.postform, .edit) .post-nav { 285 | width: 100%; 286 | display: flex; 287 | gap: 1rem; 288 | align-items: center; 289 | } 290 | 291 | :is(.postform, .edit) input[type="submit"], 292 | .login input[type="submit"] { 293 | -webkit-appearance: none; 294 | appearance: none; 295 | border: 0; 296 | display: block; 297 | background: CanvasText; 298 | color: Canvas; 299 | text-decoration: none; 300 | border-radius: 0.4rem; 301 | padding: 0.3rem 0.8rem 0.4rem; 302 | font-weight: bold; 303 | text-align: center; 304 | cursor: pointer; 305 | float: right; 306 | } 307 | 308 | :is(.postform, .edit) #count { 309 | color: CanvasText; 310 | } 311 | 312 | :is(.postform, .edit) #post-droparea { 313 | border: 0.15rem dashed CanvasText; 314 | color: CanvasText; 315 | padding: 0.25rem; 316 | cursor: pointer; 317 | } 318 | 319 | :is(.postform, .edit) #post-droparea.drag, 320 | :is(.postform, .edit) #post-droparea:is(:hover, :focus) { 321 | background-color: var(--primary-color); 322 | color: Canvas; 323 | border: 0.15rem solid var(--primary-color); 324 | } 325 | 326 | :is(.postform, .edit) #post-attachments-label { 327 | display: flex; 328 | border: 0.15rem dashed rgba(0,0,0,0.4); 329 | color: rgba(0,0,0,0.4); 330 | padding: 0.25rem; 331 | cursor: pointer; 332 | position: relative; 333 | align-self: stretch; 334 | align-items: center; 335 | } 336 | 337 | :is(.postform, .edit) #post-attachments { 338 | /* cover the entire label, for drag and drop */ 339 | display: block; 340 | position: absolute; 341 | top: 0; 342 | left: 0; 343 | width: 100%; 344 | height: 100%; 345 | opacity: 0; 346 | } 347 | 348 | :is(.postform, .edit) #post-attachments-list { 349 | flex: 1; 350 | display: flex; 351 | flex-direction: column; 352 | flex-wrap: nowrap; 353 | padding-inline-end: 1rem; 354 | align-self: stretch; 355 | justify-content: center; 356 | } 357 | 358 | :is(.postform, .edit) #post-attachments-list li + li { 359 | margin-block-start: 0.25em; 360 | border-top: 1px solid rgba(0,0,0,0.2); 361 | padding-block-start: 0.25em; 362 | } 363 | 364 | :is(.postform, .edit) #post-attachments-list img.file-preview { 365 | display: inline-block; 366 | vertical-align: middle; 367 | margin-inline-end: 1ch; 368 | width: 1.75rem; 369 | height: 1.75rem; 370 | outline: 0px solid #f0f; 371 | object-fit: cover; 372 | background: #fff; 373 | } 374 | 375 | :is(.postform, .edit) #post-attachments-list input[type="checkbox"] { 376 | -webkit-appearance: checkbox; 377 | appearance: checkbox; 378 | } 379 | 380 | :is(.timeline, .single) .post-attachments { 381 | grid-column-start: span 6; 382 | margin-block-start: 1rem; 383 | } 384 | 385 | :is(.timeline, .single) .post-attachments li + li { 386 | margin-block-start: 0.5rem; 387 | } 388 | 389 | :is(.postform, .edit, .settings) .message, 390 | .login .message { 391 | background-color: #87b26c; 392 | padding: 0.5rem; 393 | color: var(--secondary-color); 394 | border-radius: 0.4rem; 395 | margin-block-end: 0.5rem; 396 | } 397 | 398 | :is(.postform, .edit, .settings) .error, 399 | .login .error { 400 | background-color: #9c2128; 401 | } 402 | 403 | .login form { 404 | margin-block-start: 0.75rem; 405 | } 406 | 407 | .login input[type="text"], 408 | .login input[type="password"] { 409 | width: 100%; 410 | border: 2px solid CanvasText; 411 | background: Canvas; 412 | padding: 0.5rem; 413 | font-size: 1.25rem; 414 | resize: vertical; 415 | margin-block-end: 0.5rem; 416 | } 417 | 418 | .login .login-nav { 419 | display: flex; 420 | gap: 1rem; 421 | flex-direction: row-reverse; 422 | justify-content: end; 423 | align-items: center; 424 | } 425 | 426 | .login input[type="submit"] { 427 | float: none; 428 | } 429 | 430 | .login input[type="text"]:focus, 431 | .login input[type="password"]:focus { 432 | border-color: var(--primary-color); 433 | outline: none; 434 | } 435 | 436 | .settings .post-nav { 437 | display: flex; 438 | justify-content: flex-end; 439 | } 440 | 441 | .settings fieldset { 442 | margin-block: 1rem 2rem; 443 | } 444 | 445 | .settings fieldset legend { 446 | text-transform: uppercase; 447 | font-weight: 700; 448 | font-size: 85%; 449 | margin-block-end: 1rem; 450 | } 451 | 452 | .settings fieldset dl { 453 | display: grid; 454 | grid-template-columns: 2fr 3fr; 455 | grid-gap: 1.25rem 0; 456 | } 457 | 458 | .settings fieldset :is(dt, dd) { 459 | padding: 0.25em 0.5rem 0.25em 0; 460 | border-bottom: 0px solid CanvasText; 461 | outline: 0px solid red; 462 | } 463 | 464 | .settings fieldset dt label { 465 | font-size: 85%; 466 | } 467 | 468 | .settings fieldset dd :is(select, input[type="radio"], input[type="checkbox"]) { 469 | all: revert; 470 | font-size: 1rem; 471 | } 472 | 473 | .settings fieldset dd :is(input[type="text"], textarea) { 474 | width: 100%; 475 | padding: 0.25em 0.5em; 476 | border-bottom: 1px solid #aaa; 477 | } 478 | 479 | .settings fieldset dd :is(input, textarea)::placeholder { 480 | opacity: 0.4; 481 | } 482 | 483 | footer { 484 | width: min(95%, 40rem); 485 | margin: 0.5rem auto 2rem; 486 | } 487 | 488 | footer ul { 489 | list-style: none; 490 | display: flex; 491 | justify-content: center; 492 | gap: 1rem; 493 | } 494 | 495 | footer li a { 496 | color: color-mix(in oklch, CanvasText 25%, Canvas); 497 | text-decoration: none; 498 | font-size: 0.8rem; 499 | text-transform: uppercase; 500 | font-weight: bold; 501 | margin-block-end: 0.5rem; 502 | } 503 | 504 | /* 505 | @supports (background: paint(id)) { 506 | input[type="submit"] { 507 | background: paint(squircle) !important; 508 | --squircle-radius: 8px; 509 | --squircle-fill: var(--primary-color); 510 | 511 | border-radius: 0; 512 | } 513 | } 514 | */ 515 | -------------------------------------------------------------------------------- /css/plain/plain.js: -------------------------------------------------------------------------------- 1 | class THEME { 2 | constructor (params) { 3 | this.params = params; 4 | console.log('Init theme class', this.params); 5 | } 6 | 7 | demo () { 8 | console.log('Hello!'); 9 | } 10 | } 11 | 12 | // run custom theme code 13 | // const thm = new THEME('abc'); 14 | console.log('Init theme script'); 15 | 16 | export { THEME } 17 | -------------------------------------------------------------------------------- /css/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | The new CSS reset - version 1.7.3 (last updated 7.8.2022) 3 | GitHub page: https://github.com/elad2412/the-new-css-reset 4 | */ 5 | *:where(:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)) { 6 | all: unset; 7 | display: revert; 8 | } 9 | 10 | *, 11 | *::before, 12 | *::after { 13 | box-sizing: border-box; 14 | } 15 | 16 | a, button { cursor: revert; } 17 | ol, ul, menu { list-style: none; } 18 | img { max-width: 100%; } 19 | table { border-collapse: collapse; } 20 | input, textarea { -webkit-user-select: auto; } 21 | textarea { white-space: revert; } 22 | ::placeholder { color: unset; } 23 | :where([hidden]) { display: none; } 24 | -------------------------------------------------------------------------------- /css/solarized-dark/icons/icon-announce.svg: -------------------------------------------------------------------------------- 1 | 2 | Repost 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/solarized-dark/icons/icon-like.svg: -------------------------------------------------------------------------------- 1 | 2 | Like 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/solarized-dark/solarized-dark.css: -------------------------------------------------------------------------------- 1 | @import '../reset.css'; 2 | 3 | :root { 4 | --primary-color: #586e75; 5 | --secondary-color: #002b36; 6 | --background-color: #073642; 7 | --text-color: #839496; 8 | --error-color: #dc322f; 9 | } 10 | 11 | html { 12 | font: 100%/1.4 system-ui, Helvetica, sans-serif; 13 | background-color: var(--background-color); 14 | accent-color: var(--primary-color); 15 | color: var(--text-color); 16 | } 17 | 18 | img { 19 | display: block; 20 | max-width: 100%; 21 | height: auto; 22 | } 23 | 24 | option { 25 | background-color: var(--background-color); 26 | color: var(--text-color); 27 | } 28 | 29 | .wrap { 30 | width: min(95%, 40rem); 31 | margin: 2rem auto; 32 | padding: 1rem; 33 | background-color: var(--secondary-color); 34 | box-shadow: 0 1.25rem 1rem -1rem rgba(0,0,0,0.25); 35 | } 36 | 37 | .button { 38 | display: block; 39 | background: var(--primary-color); 40 | color: var(--secondary-color); 41 | text-decoration: none; 42 | border-radius: 0.4rem; 43 | padding: 0.2rem 0.5rem; 44 | font-weight: bold; 45 | text-align: center; 46 | } 47 | 48 | .button.alert { 49 | background: #cb4b16; 50 | } 51 | 52 | .hidden { 53 | display: none !important; 54 | } 55 | 56 | nav.main ul { 57 | display: flex; 58 | margin-bottom: 2rem; 59 | } 60 | 61 | nav.main li { 62 | list-style: none; 63 | flex: 1; 64 | } 65 | 66 | nav.main li a { 67 | /* inherits from .button */ 68 | } 69 | 70 | nav.main li + li a { 71 | margin-left: 0.2rem; 72 | } 73 | 74 | .wrap .posts { 75 | 76 | } 77 | 78 | .wrap .posts > li { 79 | list-style: none; 80 | margin-bottom: 1rem; 81 | border-bottom: 1px solid rgba(0,0,0,0.1); 82 | padding-bottom: 1rem; 83 | 84 | display: grid; 85 | grid-template-columns: repeat(6, 1fr); 86 | } 87 | 88 | .wrap .posts > li > * { 89 | outline: 0px solid #d33682; 90 | } 91 | 92 | .wrap .posts > li:last-child { 93 | margin-bottom: 2rem; 94 | border-bottom: 0; 95 | padding-bottom: 0; 96 | } 97 | 98 | .timeline .pagination { overflow: hidden; } 99 | .timeline .pagination a { 100 | display: block; 101 | background: var(--primary-color); 102 | color: var(--secondary-color); 103 | text-decoration: none; 104 | border-radius: 0.4rem; 105 | padding: 0.2rem 0.6rem; 106 | font-weight: bold; 107 | float: left; 108 | } 109 | .timeline .pagination .next { float: right; } 110 | 111 | .wrap .post-timestamp { 112 | display: block; 113 | color: var(--primary-color); 114 | text-decoration: none; 115 | font-size: 0.8rem; 116 | text-transform: uppercase; 117 | font-weight: bold; 118 | margin-bottom: 0.5rem; 119 | grid-column-start: span 3; 120 | } 121 | 122 | .wrap .post-timestamp time.modified { 123 | display: block; 124 | color: var(--primary-color); 125 | mix-blend-mode: multiply; 126 | } 127 | 128 | .wrap .post-meta { 129 | grid-column-start: span 3; 130 | } 131 | 132 | .wrap .post-meta ul { 133 | display: flex; 134 | justify-content: flex-end; 135 | gap: 0.75ch; 136 | } 137 | 138 | .wrap .post-meta li { 139 | list-style: none; 140 | } 141 | 142 | .wrap .post-meta li a { 143 | display: block; 144 | color: var(--primary-color); 145 | mix-blend-mode: normal; 146 | text-decoration: none; 147 | font-size: 0.8rem; 148 | text-transform: uppercase; 149 | font-weight: bold; 150 | margin-bottom: 0.5rem; 151 | } 152 | 153 | .wrap .post-meta li a:is(:hover, :focus) { 154 | color: currentColor; 155 | } 156 | 157 | .wrap .post-meta li span.amount { 158 | margin-inline-end: 0.25ch; 159 | } 160 | 161 | .wrap .post-meta li span.word { display: inline-block; text-indent: -9999px; } 162 | .wrap .post-meta li span.amount::after { 163 | display: inline-block; 164 | content: ''; 165 | background-repeat: no-repeat; 166 | background-position: center center; 167 | background-size: contain; 168 | vertical-align: middle; 169 | margin-inline-start: 0.25ch; 170 | opacity: 0.25; 171 | } 172 | 173 | .wrap .post-meta li a:is(:hover, :focus) span.amount::after { 174 | opacity: 1; 175 | } 176 | 177 | .wrap .post-meta li.post-likes span.amount::after { 178 | background-image: url(./icons/icon-like.svg); 179 | width: 0.7rem; 180 | height: 0.7rem; 181 | } 182 | 183 | .wrap .post-meta li.post-boosts span.amount::after { 184 | background-image: url(./icons/icon-announce.svg); 185 | width: 1rem; 186 | height: 1rem; 187 | } 188 | 189 | .wrap .post-content { 190 | font-size: 1.25rem; 191 | overflow-wrap: break-word; 192 | grid-column-start: span 6; 193 | } 194 | 195 | .wrap .post-content a { 196 | color: var(--primary-color); 197 | text-decoration: none; 198 | } 199 | 200 | .wrap form.delete { 201 | width: 100%; 202 | grid-column-start: span 6; 203 | display: flex; 204 | margin-block-start: 2rem; 205 | } 206 | 207 | .wrap form.delete input[type="submit"] { 208 | flex: 1; 209 | line-height: 1.4; 210 | cursor: pointer; 211 | } 212 | 213 | .wrap .posts li .message { 214 | width: 100%; 215 | grid-column-start: span 6; 216 | margin-block-start: 2rem; 217 | } 218 | 219 | .postform form, 220 | form.edit, 221 | .login form { 222 | grid-column-start: span 6; 223 | overflow: hidden; 224 | } 225 | 226 | :is(.postform, .edit) textarea { 227 | width: 100%; 228 | border: 2px solid var(--background-color); 229 | background: #073642; 230 | padding: 0.5rem; 231 | font-size: 1.25rem; 232 | resize: vertical; 233 | min-height: 10rem; 234 | margin-bottom: 0.5rem; 235 | } 236 | 237 | :is(.postform, .edit) textarea:focus { 238 | border-color: var(--primary-color); 239 | outline: none; 240 | } 241 | 242 | :is(.postform, .edit) .post-nav { 243 | width: 100%; 244 | display: flex; 245 | gap: 1rem; 246 | align-items: center; 247 | } 248 | 249 | :is(.postform, .edit) input[type="submit"], 250 | .login input[type="submit"] { 251 | -webkit-appearance: none; 252 | appearance: none; 253 | border: 0; 254 | display: block; 255 | background: var(--primary-color); 256 | color: var(--secondary-color); 257 | text-decoration: none; 258 | border-radius: 0.4rem; 259 | padding: 0.3rem 0.8rem 0.4rem; 260 | font-weight: bold; 261 | text-align: center; 262 | cursor: pointer; 263 | float: right; 264 | } 265 | 266 | :is(.postform, .edit) #count { 267 | color: var(--text-color); 268 | } 269 | 270 | :is(.postform, .edit) #post-droparea { 271 | border: 0.15rem dashed var(--text-color); 272 | color: var(--text-color); 273 | padding: 0.25rem; 274 | cursor: pointer; 275 | } 276 | 277 | :is(.postform, .edit) #post-droparea.drag, 278 | :is(.postform, .edit) #post-droparea:is(:hover, :focus) { 279 | background-color: var(--primary-color); 280 | color: #073642; 281 | border: 0.15rem solid var(--primary-color); 282 | } 283 | 284 | :is(.postform, .edit) #post-attachments-label { 285 | display: flex; 286 | border: 0.15rem dashed rgba(0,0,0,0.4); 287 | color: rgba(0,0,0,0.4); 288 | padding: 0.25rem; 289 | cursor: pointer; 290 | position: relative; 291 | align-self: stretch; 292 | align-items: center; 293 | } 294 | 295 | :is(.postform, .edit) #post-attachments { 296 | /* cover the entire label, for drag and drop */ 297 | display: block; 298 | position: absolute; 299 | top: 0; 300 | left: 0; 301 | width: 100%; 302 | height: 100%; 303 | opacity: 0; 304 | } 305 | 306 | :is(.postform, .edit) #post-attachments-list { 307 | flex: 1; 308 | display: flex; 309 | flex-direction: column; 310 | flex-wrap: nowrap; 311 | padding-inline-end: 1rem; 312 | align-self: stretch; 313 | justify-content: center; 314 | } 315 | 316 | :is(.postform, .edit) #post-attachments-list li + li { 317 | margin-block-start: 0.25em; 318 | border-top: 1px solid rgba(0,0,0,0.2); 319 | padding-block-start: 0.25em; 320 | } 321 | 322 | :is(.postform, .edit) #post-attachments-list img.file-preview { 323 | display: inline-block; 324 | vertical-align: middle; 325 | margin-right: 1ch; 326 | width: 1.75rem; 327 | height: 1.75rem; 328 | outline: 0px solid #d33862; 329 | object-fit: cover; 330 | background: #073642; 331 | } 332 | 333 | :is(.postform, .edit) #post-attachments-list input[type="checkbox"] { 334 | -webkit-appearance: checkbox; 335 | appearance: checkbox; 336 | } 337 | 338 | :is(.timeline, .single) .post-attachments { 339 | grid-column-start: span 6; 340 | margin-block-start: 1rem; 341 | } 342 | 343 | :is(.timeline, .single) .post-attachments li + li { 344 | margin-block-start: 0.5rem; 345 | } 346 | 347 | :is(.postform, .edit) .message, 348 | .login .message { 349 | background-color: #859900; 350 | padding: 0.5rem; 351 | color: var(--secondary-color); 352 | border-radius: 0.4rem; 353 | margin-bottom: 0.5rem; 354 | } 355 | 356 | :is(.postform, .edit) .error, 357 | .login .error { 358 | background-color: var(--error-color); 359 | } 360 | 361 | .login form { 362 | margin-top: 0.75rem; 363 | } 364 | 365 | .login input[type="text"], 366 | .login input[type="password"] { 367 | width: 100%; 368 | border: 2px solid var(--background-color); 369 | background: #073642; 370 | padding: 0.5rem; 371 | font-size: 1.25rem; 372 | resize: vertical; 373 | margin-bottom: 0.5rem; 374 | } 375 | 376 | 377 | .login .login-nav { 378 | display: flex; 379 | gap: 1rem; 380 | flex-direction: row-reverse; 381 | justify-content: end; 382 | align-items: center; 383 | } 384 | 385 | .login input[type="submit"] { 386 | float: none; 387 | } 388 | 389 | .login input[type="text"]:focus, 390 | .login input[type="password"]:focus { 391 | border-color: var(--primary-color); 392 | outline: none; 393 | } 394 | 395 | .settings .post-nav { 396 | display: flex; 397 | justify-content: flex-end; 398 | } 399 | 400 | .settings fieldset { 401 | margin-block: 1rem 2rem; 402 | } 403 | 404 | .settings fieldset legend { 405 | text-transform: uppercase; 406 | font-weight: 700; 407 | font-size: 85%; 408 | margin-block-end: 1rem; 409 | } 410 | 411 | .settings fieldset dl { 412 | display: grid; 413 | grid-template-columns: 2fr 3fr; 414 | grid-gap: 1.25rem 0; 415 | } 416 | 417 | .settings fieldset :is(dt, dd) { 418 | padding: 0.25em 0.5rem 0.25em 0; 419 | border-bottom: 0px solid CanvasText; 420 | outline: 0px solid var(--error-color); 421 | } 422 | 423 | .settings fieldset dt label { 424 | font-size: 85%; 425 | } 426 | 427 | .settings fieldset dd :is(select, input[type="radio"], input[type="checkbox"]) { 428 | all: revert; 429 | font-size: 1rem; 430 | } 431 | 432 | .settings fieldset dd :is(input[type="text"], textarea) { 433 | width: 100%; 434 | padding: 0.25em 0.5em; 435 | border-bottom: 1px solid #93a1a1; 436 | } 437 | 438 | .settings fieldset dd :is(input, textarea)::placeholder { 439 | opacity: 0.4; 440 | } 441 | 442 | footer { 443 | width: min(95%, 40rem); 444 | margin: 0.5rem auto 2rem; 445 | } 446 | 447 | footer ul { 448 | list-style: none; 449 | display: flex; 450 | justify-content: center; 451 | gap: 1rem; 452 | } 453 | 454 | footer li a { 455 | color: hsla(0, 0%, 0%, 0.9); 456 | text-decoration: none; 457 | font-size: 0.8rem; 458 | text-transform: uppercase; 459 | font-weight: bold; 460 | margin-bottom: 0.5rem; 461 | } 462 | 463 | /* 464 | @supports (background: paint(id)) { 465 | input[type="submit"] { 466 | background: paint(squircle) !important; 467 | --squircle-radius: 8px; 468 | --squircle-fill: var(--primary-color); 469 | 470 | border-radius: 0; 471 | } 472 | } 473 | */ 474 | -------------------------------------------------------------------------------- /css/solarized-light/icons/icon-announce.svg: -------------------------------------------------------------------------------- 1 | 2 | Repost 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/solarized-light/icons/icon-like.svg: -------------------------------------------------------------------------------- 1 | 2 | Like 3 | 4 | 5 | -------------------------------------------------------------------------------- /css/solarized-light/solarized-light.css: -------------------------------------------------------------------------------- 1 | @import '../reset.css'; 2 | 3 | :root { 4 | --primary-color: #839496; 5 | --secondary-color: #fdf6e3; 6 | --background-color: #eee8d5; 7 | --text-color: #586e75; 8 | --error-color: #dc322f; 9 | } 10 | 11 | html { 12 | font: 100%/1.4 system-ui, Helvetica, sans-serif; 13 | background-color: var(--background-color); 14 | accent-color: var(--primary-color); 15 | color: var(--text-color); 16 | } 17 | 18 | img { 19 | display: block; 20 | max-width: 100%; 21 | height: auto; 22 | } 23 | 24 | option { 25 | background-color: var(--background-color); 26 | color: var(--text-color); 27 | } 28 | 29 | .wrap { 30 | width: min(95%, 40rem); 31 | margin: 2rem auto; 32 | padding: 1rem; 33 | background-color: var(--secondary-color); 34 | box-shadow: 0 1.25rem 1rem -1rem rgba(0,0,0,0.25); 35 | } 36 | 37 | .button { 38 | display: block; 39 | background: var(--primary-color); 40 | color: var(--secondary-color); 41 | text-decoration: none; 42 | border-radius: 0.4rem; 43 | padding: 0.2rem 0.5rem; 44 | font-weight: bold; 45 | text-align: center; 46 | } 47 | 48 | .button.alert { 49 | background: #cb4b16; 50 | } 51 | 52 | .hidden { 53 | display: none !important; 54 | } 55 | 56 | nav.main ul { 57 | display: flex; 58 | margin-bottom: 2rem; 59 | } 60 | 61 | nav.main li { 62 | list-style: none; 63 | flex: 1; 64 | } 65 | 66 | nav.main li a { 67 | /* inherits from .button */ 68 | } 69 | 70 | nav.main li + li a { 71 | margin-left: 0.2rem; 72 | } 73 | 74 | .wrap .posts { 75 | 76 | } 77 | 78 | .wrap .posts > li { 79 | list-style: none; 80 | margin-bottom: 1rem; 81 | border-bottom: 1px solid rgba(0,0,0,0.1); 82 | padding-bottom: 1rem; 83 | 84 | display: grid; 85 | grid-template-columns: repeat(6, 1fr); 86 | } 87 | 88 | .wrap .posts > li > * { 89 | outline: 0px solid #d33682; 90 | } 91 | 92 | .wrap .posts > li:last-child { 93 | margin-bottom: 2rem; 94 | border-bottom: 0; 95 | padding-bottom: 0; 96 | } 97 | 98 | .timeline .pagination { overflow: hidden; } 99 | .timeline .pagination a { 100 | display: block; 101 | background: var(--primary-color); 102 | color: var(--secondary-color); 103 | text-decoration: none; 104 | border-radius: 0.4rem; 105 | padding: 0.2rem 0.6rem; 106 | font-weight: bold; 107 | float: left; 108 | } 109 | .timeline .pagination .next { float: right; } 110 | 111 | .wrap .post-timestamp { 112 | display: block; 113 | color: var(--primary-color); 114 | text-decoration: none; 115 | font-size: 0.8rem; 116 | text-transform: uppercase; 117 | font-weight: bold; 118 | margin-bottom: 0.5rem; 119 | grid-column-start: span 3; 120 | } 121 | 122 | .wrap .post-timestamp time.modified { 123 | display: block; 124 | color: hsla(0, 0%, 0%, 0.2); 125 | mix-blend-mode: multiply; 126 | } 127 | 128 | .wrap .post-meta { 129 | grid-column-start: span 3; 130 | } 131 | 132 | .wrap .post-meta ul { 133 | display: flex; 134 | justify-content: flex-end; 135 | gap: 0.75ch; 136 | } 137 | 138 | .wrap .post-meta li { 139 | list-style: none; 140 | } 141 | 142 | .wrap .post-meta li a { 143 | display: block; 144 | color: hsla(0, 0%, 0%, 0.2); 145 | mix-blend-mode: multiply; 146 | text-decoration: none; 147 | font-size: 0.8rem; 148 | text-transform: uppercase; 149 | font-weight: bold; 150 | margin-bottom: 0.5rem; 151 | } 152 | 153 | .wrap .post-meta li a:is(:hover, :focus) { 154 | color: currentColor; 155 | } 156 | 157 | .wrap .post-meta li span.amount { 158 | margin-inline-end: 0.25ch; 159 | } 160 | 161 | .wrap .post-meta li span.word { display: inline-block; text-indent: -9999px; } 162 | .wrap .post-meta li span.amount::after { 163 | display: inline-block; 164 | content: ''; 165 | background-repeat: no-repeat; 166 | background-position: center center; 167 | background-size: contain; 168 | vertical-align: middle; 169 | margin-inline-start: 0.25ch; 170 | opacity: 0.25; 171 | } 172 | 173 | .wrap .post-meta li a:is(:hover, :focus) span.amount::after { 174 | opacity: 1; 175 | } 176 | 177 | .wrap .post-meta li.post-likes span.amount::after { 178 | background-image: url(./icons/icon-like.svg); 179 | width: 0.7rem; 180 | height: 0.7rem; 181 | } 182 | 183 | .wrap .post-meta li.post-boosts span.amount::after { 184 | background-image: url(./icons/icon-announce.svg); 185 | width: 1rem; 186 | height: 1rem; 187 | } 188 | 189 | .wrap .post-content { 190 | font-size: 1.25rem; 191 | overflow-wrap: break-word; 192 | grid-column-start: span 6; 193 | } 194 | 195 | .wrap .post-content a { 196 | color: var(--primary-color); 197 | text-decoration: none; 198 | } 199 | 200 | .wrap form.delete { 201 | width: 100%; 202 | grid-column-start: span 6; 203 | display: flex; 204 | margin-block-start: 2rem; 205 | } 206 | 207 | .wrap form.delete input[type="submit"] { 208 | flex: 1; 209 | line-height: 1.4; 210 | cursor: pointer; 211 | } 212 | 213 | .wrap .posts li .message { 214 | width: 100%; 215 | grid-column-start: span 6; 216 | margin-block-start: 2rem; 217 | } 218 | 219 | .postform form, 220 | form.edit, 221 | .login form { 222 | grid-column-start: span 6; 223 | overflow: hidden; 224 | } 225 | 226 | :is(.postform, .edit) textarea { 227 | width: 100%; 228 | border: 2px solid var(--background-color); 229 | background: #073642; 230 | padding: 0.5rem; 231 | font-size: 1.25rem; 232 | resize: vertical; 233 | min-height: 10rem; 234 | margin-bottom: 0.5rem; 235 | } 236 | 237 | :is(.postform, .edit) textarea:focus { 238 | border-color: var(--primary-color); 239 | outline: none; 240 | } 241 | 242 | :is(.postform, .edit) .post-nav { 243 | width: 100%; 244 | display: flex; 245 | gap: 1rem; 246 | align-items: center; 247 | } 248 | 249 | :is(.postform, .edit) input[type="submit"], 250 | .login input[type="submit"] { 251 | -webkit-appearance: none; 252 | appearance: none; 253 | border: 0; 254 | display: block; 255 | background: var(--primary-color); 256 | color: var(--secondary-color); 257 | text-decoration: none; 258 | border-radius: 0.4rem; 259 | padding: 0.3rem 0.8rem 0.4rem; 260 | font-weight: bold; 261 | text-align: center; 262 | cursor: pointer; 263 | float: right; 264 | } 265 | 266 | :is(.postform, .edit) #count { 267 | color: var(--text-color); 268 | } 269 | 270 | :is(.postform, .edit) #post-droparea { 271 | border: 0.15rem dashed var(--text-color); 272 | color: var(--text-color); 273 | padding: 0.25rem; 274 | cursor: pointer; 275 | } 276 | 277 | :is(.postform, .edit) #post-droparea.drag, 278 | :is(.postform, .edit) #post-droparea:is(:hover, :focus) { 279 | background-color: var(--primary-color); 280 | color: #073642; 281 | border: 0.15rem solid var(--primary-color); 282 | } 283 | 284 | :is(.postform, .edit) #post-attachments-label { 285 | display: flex; 286 | border: 0.15rem dashed rgba(0,0,0,0.4); 287 | color: rgba(0,0,0,0.4); 288 | padding: 0.25rem; 289 | cursor: pointer; 290 | position: relative; 291 | align-self: stretch; 292 | align-items: center; 293 | } 294 | 295 | :is(.postform, .edit) #post-attachments { 296 | /* cover the entire label, for drag and drop */ 297 | display: block; 298 | position: absolute; 299 | top: 0; 300 | left: 0; 301 | width: 100%; 302 | height: 100%; 303 | opacity: 0; 304 | } 305 | 306 | :is(.postform, .edit) #post-attachments-list { 307 | flex: 1; 308 | display: flex; 309 | flex-direction: column; 310 | flex-wrap: nowrap; 311 | padding-inline-end: 1rem; 312 | align-self: stretch; 313 | justify-content: center; 314 | } 315 | 316 | :is(.postform, .edit) #post-attachments-list li + li { 317 | margin-block-start: 0.25em; 318 | border-top: 1px solid rgba(0,0,0,0.2); 319 | padding-block-start: 0.25em; 320 | } 321 | 322 | :is(.postform, .edit) #post-attachments-list img.file-preview { 323 | display: inline-block; 324 | vertical-align: middle; 325 | margin-right: 1ch; 326 | width: 1.75rem; 327 | height: 1.75rem; 328 | outline: 0px solid #d33862; 329 | object-fit: cover; 330 | background: #073642; 331 | } 332 | 333 | :is(.postform, .edit) #post-attachments-list input[type="checkbox"] { 334 | -webkit-appearance: checkbox; 335 | appearance: checkbox; 336 | } 337 | 338 | :is(.timeline, .single) .post-attachments { 339 | grid-column-start: span 6; 340 | margin-block-start: 1rem; 341 | } 342 | 343 | :is(.timeline, .single) .post-attachments li + li { 344 | margin-block-start: 0.5rem; 345 | } 346 | 347 | :is(.postform, .edit) .message, 348 | .login .message { 349 | background-color: #859900; 350 | padding: 0.5rem; 351 | color: var(--secondary-color); 352 | border-radius: 0.4rem; 353 | margin-bottom: 0.5rem; 354 | } 355 | 356 | :is(.postform, .edit) .error, 357 | .login .error { 358 | background-color: var(--error-color); 359 | } 360 | 361 | .login form { 362 | margin-top: 0.75rem; 363 | } 364 | 365 | .login input[type="text"], 366 | .login input[type="password"] { 367 | width: 100%; 368 | border: 2px solid var(--background-color); 369 | background: #073642; 370 | padding: 0.5rem; 371 | font-size: 1.25rem; 372 | resize: vertical; 373 | margin-bottom: 0.5rem; 374 | } 375 | 376 | 377 | .login .login-nav { 378 | display: flex; 379 | gap: 1rem; 380 | flex-direction: row-reverse; 381 | justify-content: end; 382 | align-items: center; 383 | } 384 | 385 | .login input[type="submit"] { 386 | float: none; 387 | } 388 | 389 | .login input[type="text"]:focus, 390 | .login input[type="password"]:focus { 391 | border-color: var(--primary-color); 392 | outline: none; 393 | } 394 | 395 | .settings .post-nav { 396 | display: flex; 397 | justify-content: flex-end; 398 | } 399 | 400 | .settings fieldset { 401 | margin-block: 1rem 2rem; 402 | } 403 | 404 | .settings fieldset legend { 405 | text-transform: uppercase; 406 | font-weight: 700; 407 | font-size: 85%; 408 | margin-block-end: 1rem; 409 | } 410 | 411 | .settings fieldset dl { 412 | display: grid; 413 | grid-template-columns: 2fr 3fr; 414 | grid-gap: 1.25rem 0; 415 | } 416 | 417 | .settings fieldset :is(dt, dd) { 418 | padding: 0.25em 0.5rem 0.25em 0; 419 | border-bottom: 0px solid CanvasText; 420 | outline: 0px solid var(--error-color); 421 | } 422 | 423 | .settings fieldset dt label { 424 | font-size: 85%; 425 | } 426 | 427 | .settings fieldset dd :is(select, input[type="radio"], input[type="checkbox"]) { 428 | all: revert; 429 | font-size: 1rem; 430 | } 431 | 432 | .settings fieldset dd :is(input[type="text"], textarea) { 433 | width: 100%; 434 | padding: 0.25em 0.5em; 435 | border-bottom: 1px solid #93a1a1; 436 | } 437 | 438 | .settings fieldset dd :is(input, textarea)::placeholder { 439 | opacity: 0.4; 440 | } 441 | 442 | footer { 443 | width: min(95%, 40rem); 444 | margin: 0.5rem auto 2rem; 445 | } 446 | 447 | footer ul { 448 | list-style: none; 449 | display: flex; 450 | justify-content: center; 451 | gap: 1rem; 452 | } 453 | 454 | footer li a { 455 | color: hsla(0, 0%, 0%, 0.3); 456 | text-decoration: none; 457 | font-size: 0.8rem; 458 | text-transform: uppercase; 459 | font-weight: bold; 460 | margin-bottom: 0.5rem; 461 | } 462 | 463 | /* 464 | @supports (background: paint(id)) { 465 | input[type="submit"] { 466 | background: paint(squircle) !important; 467 | --squircle-radius: 8px; 468 | --squircle-fill: var(--primary-color); 469 | 470 | border-radius: 0; 471 | } 472 | } 473 | */ 474 | -------------------------------------------------------------------------------- /favicon-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oelna/microblog/1ecb62f2ba33b37210ae3d254140a656aa973f06/favicon-large.png -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oelna/microblog/1ecb62f2ba33b37210ae3d254140a656aa973f06/favicon.ico -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | $bytes, 'settings_updated' => $age) = db_get_setting('magic_url', true); 110 | if(empty($bytes)) exit('Invalid URL'); 111 | 112 | // validate 113 | if(path(1) === $bytes) { 114 | 115 | // check link age (valid for 1h) 116 | if($age > NOW - 3600) { 117 | $config['logged_in'] = check_login(true); // force entry! 118 | 119 | header('Location: '.$config['url'].'/settings'); 120 | exit('Success'); 121 | } 122 | 123 | exit('Link has expired'); 124 | } else { 125 | exit('Invalid URL'); 126 | } 127 | } else { 128 | if (empty($_GET['token'])) { 129 | header('Location: '.$config['url'].'/login?invalid'); 130 | break; 131 | } 132 | 133 | if ($_GET['token'] < time() - 60*5 ) { 134 | // if not requested in the last 5 minutes 135 | header('Location: '.$config['url'].'/login?invalid'); 136 | break; 137 | } 138 | 139 | // send a recovery email with link 140 | $bytes = bin2hex(random_bytes(16)); 141 | $magic_link = $config['url'].'/recovery/'.$bytes; 142 | 143 | db_set_setting('magic_url', $bytes); 144 | 145 | $mailtext = 'Your recovery link for Microblog:'.NL; 146 | $mailtext .= $magic_link.NL; 147 | 148 | // $browser = get_browser(null, true); 149 | $mailtext .= NL.NL.'Request Information'.NL; 150 | $mailtext .= 'IP: '.$_SERVER['REMOTE_ADDR'].NL; 151 | $mailtext .= 'User Agent: '.$_SERVER['HTTP_USER_AGENT'].NL; 152 | $mailtext .= 'Time: '.date('Y-m-d H:i:s', $_GET['token']).NL; 153 | // $mailtext .= 'Browser: '.$browser['parent'].NL; 154 | // $mailtext .= 'OS: '.$browser['platform'].NL; 155 | 156 | $host = parse_url($config['url'], PHP_URL_HOST); 157 | $headers = array( 158 | 'From' => 'admin@'.$host, 159 | 'Reply-To' => 'admin@'.$host, 160 | 'X-Mailer' => 'PHP/' . phpversion() 161 | ); 162 | 163 | if(mail(trim($config['admin_email']), 'Your Microblog recovery link', $mailtext, $headers)) { 164 | // var_dump($mailtext); 165 | header('Location: '.$config['url'].'/login/recovery?success'); 166 | } else { 167 | exit('Could not send email with recovery link!'); 168 | } 169 | } 170 | } 171 | 172 | break; 173 | default: 174 | // redirect everything else to the homepage 175 | if(!empty(path(0)) && path(0) != 'page') { 176 | // die(path(0) . path(1) . 'WTF'); 177 | header('Location: '.$config['url']); 178 | die(); 179 | } 180 | 181 | // show the homepage 182 | require_once(ROOT.DS.'templates'.DS.'timeline.inc.php'); 183 | break; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /js/microblog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { PK } from './passkeys.js'; 4 | 5 | document.documentElement.classList.remove('no-js'); 6 | 7 | const textarea = document.querySelector('textarea[name="content"]'); 8 | const charCount = document.querySelector('#count'); 9 | 10 | if (textarea) { 11 | const maxCount = parseInt(textarea.getAttribute('maxlength')); 12 | 13 | if (textarea.value.length > 0) { 14 | const textLength = [...textarea.value].length; 15 | charCount.textContent = maxCount - textLength; 16 | } else { 17 | charCount.textContent = maxCount; 18 | } 19 | 20 | textarea.addEventListener('input', function () { 21 | const textLength = [...this.value].length; 22 | 23 | charCount.textContent = maxCount - textLength; 24 | }, false); 25 | } 26 | 27 | const postForm = document.querySelector('#post-new-form'); 28 | let useDragDrop = (!!window.FileReader && 'draggable' in document.createElement('div')); 29 | // useDragDrop = false; // remove, only for testing! 30 | if (postForm) { 31 | const droparea = postForm.querySelector('#post-droparea'); 32 | const attachmentsInput = postForm.querySelector('#post-attachments'); 33 | const data = { 34 | 'attachments': [] 35 | }; 36 | 37 | if (droparea && attachmentsInput) { 38 | if (useDragDrop) { 39 | console.log('init with modern file attachments'); 40 | 41 | const list = postForm.querySelector('#post-attachments-list'); 42 | list.addEventListener('click', function (e) { 43 | e.preventDefault(); 44 | 45 | // remove attachment 46 | if (e.target.nodeName.toLowerCase() == 'li') { 47 | const filename = e.target.textContent; 48 | 49 | data.attachments = data.attachments.filter(function (ele) { 50 | return ele.name !== filename; 51 | }); 52 | 53 | e.target.remove(); 54 | } 55 | }); 56 | 57 | droparea.classList.remove('hidden'); 58 | document.querySelector('#post-attachments-label').classList.add('hidden'); 59 | 60 | droparea.ondragover = droparea.ondragenter = function (e) { 61 | e.stopPropagation(); 62 | e.preventDefault(); 63 | 64 | e.dataTransfer.dropEffect = 'copy'; 65 | e.target.classList.add('drag'); 66 | }; 67 | 68 | droparea.ondragleave = function (e) { 69 | e.target.classList.remove('drag'); 70 | }; 71 | 72 | droparea.onclick = function (e) { 73 | e.preventDefault(); 74 | 75 | // make a virtual file upload 76 | const input = document.createElement('input'); 77 | input.type = 'file'; 78 | input.setAttribute('multiple', ''); 79 | input.setAttribute('accept', 'image/*'); // only images for now 80 | 81 | input.onchange = e => { 82 | processSelectedFiles(e.target.files); 83 | } 84 | 85 | input.click(); 86 | }; 87 | 88 | function processSelectedFiles(files) { 89 | if (!files || files.length < 1) return; 90 | 91 | for (const file of files) { 92 | const found = data.attachments.find(ele => ele.name === file.name); 93 | if(found) continue; // skip existing attachments 94 | 95 | data.attachments.push({ 96 | 'name': file.name, 97 | // todo: maybe some better form of dupe detection here? 98 | 'file': file 99 | }); 100 | 101 | const li = document.createElement('li'); 102 | li.textContent = file.name; 103 | 104 | const reader = new FileReader(); 105 | 106 | if(file.type.startsWith('image/')) { 107 | reader.onload = function (e) { 108 | var dataURL = e.target.result; 109 | 110 | const preview = document.createElement('img'); 111 | preview.classList.add('file-preview'); 112 | preview.setAttribute('src', dataURL); 113 | 114 | li.prepend(preview); 115 | }; 116 | reader.onerror = function (e) { 117 | console.log('An error occurred during file input: '+e.target.error.code); 118 | }; 119 | 120 | reader.readAsDataURL(file); 121 | } 122 | 123 | list.append(li); 124 | } 125 | } 126 | 127 | droparea.ondrop = function (e) { 128 | if (e.dataTransfer) { 129 | e.preventDefault(); 130 | e.stopPropagation(); 131 | 132 | processSelectedFiles(e.dataTransfer.files); 133 | } 134 | 135 | e.target.classList.remove('drag'); 136 | }; 137 | 138 | postForm.addEventListener('submit', async function (e) { 139 | e.preventDefault(); 140 | 141 | const postFormData = new FormData(); 142 | 143 | postFormData.append('content', postForm.querySelector('[name="content"]').value); 144 | 145 | for (const attachment of data.attachments) { 146 | postFormData.append('attachments[]', attachment.file); 147 | } 148 | 149 | /* 150 | for (const pair of postFormData.entries()) { 151 | console.log(`${pair[0]}, ${pair[1]}`); 152 | } 153 | */ 154 | 155 | const response = await fetch(postForm.getAttribute('action'), { 156 | body: postFormData, 157 | method: 'POST' 158 | }); 159 | 160 | if (response.ok && response.status == 200) { 161 | const txt = await response.text(); 162 | // console.log('form result', response, txt); 163 | window.location.href = postForm.dataset.redirect + '?t=' + Date.now(); 164 | } else { 165 | console.warn('error during post submission!', response); 166 | } 167 | }); 168 | } else { 169 | // use the native file input dialog 170 | // but enhanced 171 | if (attachmentsInput) { 172 | console.log('init with classic file attachments'); 173 | 174 | attachmentsInput.addEventListener('change', function (e) { 175 | console.log(e.target.files); 176 | 177 | const list = postForm.querySelector('#post-attachments-list'); 178 | list.replaceChildren(); 179 | 180 | for (const file of e.target.files) { 181 | const li = document.createElement('li'); 182 | li.textContent = file.name; 183 | list.append(li); 184 | } 185 | }); 186 | } 187 | } 188 | } 189 | } 190 | 191 | // better rounded corners 192 | if ('paintWorklet' in CSS) { 193 | // CSS.paintWorklet.addModule('./js/squircle.js'); 194 | } 195 | 196 | // PASSKEY SUPPORT 197 | const pk = new PK({ 198 | 'urls': { 199 | 'home': mb.url.origin, 200 | 'create': mb.url.origin+'/pk/create', 201 | 'store': mb.url.origin+'/pk/store', 202 | 'login': mb.url.origin+'/pk/login', 203 | 'verify': mb.url.origin+'/pk/verify', 204 | 'revoke': mb.url.origin+'/pk/revoke' 205 | }, 206 | 'dom': { 207 | 'create': '#passkey-create', 208 | 'revoke': '#passkey-revoke', 209 | 'login': '#passkey-login', 210 | 'status': '#passkey-status' 211 | }, 212 | }); 213 | 214 | // import scripts from themes 215 | try { 216 | const themeScript = '/css/'+mb.theme.name+'/'+mb.theme.name+'.js'; 217 | await import(themeScript); 218 | 219 | // const { THEME } = await import(themeScript); 220 | // if (THEME) mb.theme.script = new THEME(); 221 | } catch (e) { 222 | console.error('Unable to load theme script', e); 223 | } 224 | -------------------------------------------------------------------------------- /js/passkeys.js: -------------------------------------------------------------------------------- 1 | // PASSKEY MODULE FOR MICROBLOG 2 | 3 | // a lot of the following code has been taken from 4 | // https://github.com/craigfrancis/webauthn-tidy (BSD 3) 5 | // Copyright 2020 Craig Francis 6 | // with modifications by Arno Richter in 2023 7 | // for his Microblog software 8 | 9 | class PK { 10 | constructor (params) { 11 | this.urls = params.urls; 12 | this.dom = {}; 13 | for (const [key, value] of Object.entries(params.dom)) { 14 | this.dom[key] = document.querySelector(value); 15 | } 16 | 17 | this.textEncoder = new TextEncoder(); 18 | this.textDecoder = new TextDecoder('utf-8'); 19 | 20 | this.support = null; 21 | setTimeout(this.init, 10, this); 22 | } 23 | 24 | async init (self) { 25 | self.support = await self.detect(); 26 | 27 | if(self.dom.create) { 28 | self.dom.create.addEventListener('click', async function (e) { 29 | e.preventDefault(); 30 | await self.create(); 31 | }); 32 | } 33 | 34 | if(self.dom.revoke) { 35 | self.dom.revoke.addEventListener('click', async function (e) { 36 | e.preventDefault(); 37 | await self.revoke(); 38 | }); 39 | } 40 | 41 | if(self.dom.login) { 42 | self.dom.login.addEventListener('click', async function (e) { 43 | e.preventDefault(); 44 | await self.login(); 45 | }); 46 | } 47 | 48 | console.log('Initialized Passkey UI'); 49 | } 50 | 51 | async detect () { 52 | const results = await Promise.all([ 53 | PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), 54 | PublicKeyCredential.isConditionalMediationAvailable() 55 | ]); 56 | 57 | if (results.every(r => r === true)) { 58 | console.log('Passkey support available'); 59 | if(this.dom.create) this.dom.create.classList.remove('hidden'); 60 | if(this.dom.login) this.dom.login.classList.remove('hidden'); 61 | 62 | document.documentElement.classList.add('passkeys'); 63 | 64 | return true; 65 | } else document.documentElement.classList.add('no-passkeys'); 66 | 67 | return false; 68 | } 69 | 70 | async create (event) { 71 | if (!this.support) return false; 72 | 73 | const optionsRequest = await fetch(this.urls.create, { 74 | 'method': 'GET', 75 | 'headers': { 76 | 'Accept': 'application/json', 77 | 'Content-Type': 'application/json' 78 | } 79 | }); 80 | const options = await optionsRequest.json(); 81 | 82 | options['publicKey']['challenge'] = this.base64ToUint8array(options['publicKey']['challenge']); 83 | options['publicKey']['user']['id'] = this.textToUint8array(options['publicKey']['user']['id']); 84 | 85 | if (options['publicKey']['excludeCredentials'].length > 0) { 86 | for (var k = (options['publicKey']['excludeCredentials'].length - 1); k >= 0; k--) { 87 | options['publicKey']['excludeCredentials'][k]['id'] = this.base64ToUint8array(options['publicKey']['excludeCredentials'][k]['id']); 88 | } 89 | } 90 | 91 | let result = null; 92 | try { 93 | result = await navigator.credentials.create(options); 94 | console.log(result); 95 | } catch (e) { 96 | if (e.name == 'InvalidStateError') { 97 | console.error('error', e.name, e.message); 98 | alert('You already seem to have a passkey on file! You have to revoke it first to set a new one.'); 99 | } else { 100 | console.error('error', e.name, e.message); 101 | } 102 | return false; 103 | } 104 | 105 | if (!result) return false; 106 | 107 | var output = { 108 | 'id': result.id.replace(/-/g, '+').replace(/_/g, '/'), // Use normal base64, not base64url (rfc4648) 109 | 'type': result.type, 110 | 'response': { 111 | 'clientDataJSON': this.bufferToBase64(result.response.clientDataJSON), 112 | 'authenticatorData': this.bufferToBase64(result.response.getAuthenticatorData()), 113 | 'publicKey': this.bufferToBase64(result.response.getPublicKey()), 114 | 'publicKeyAlg': result.response.getPublicKeyAlgorithm() 115 | } 116 | }; 117 | 118 | const saveRequest = await fetch(this.urls.store, { 119 | 'method': 'POST', 120 | 'headers': { 121 | 'Accept': 'application/json', 122 | 'Content-Type': 'application/json' 123 | }, 124 | 'body': JSON.stringify(output) 125 | }); 126 | const response = await saveRequest.json(); 127 | 128 | if (response.result > -1) { 129 | console.info('passkey setup successful', response.result); 130 | this.dom.status.innerText = 'New Passkey was saved!'; 131 | } else { 132 | console.error('passkey setup failed (passkey already present in DB)', response.result); 133 | } 134 | } 135 | 136 | async login (event) { 137 | if (!this.support) return false; 138 | 139 | const optionsRequest = await fetch(this.urls.login, { 140 | 'method': 'GET', 141 | 'headers': { 142 | 'Accept': 'application/json', 143 | 'Content-Type': 'application/json' 144 | } 145 | }); 146 | const options = await optionsRequest.json(); 147 | console.log(options); 148 | options['publicKey']['challenge'] = this.base64ToUint8array(options['publicKey']['challenge']); 149 | 150 | for (var k = (options['publicKey']['allowCredentials'].length - 1); k >= 0; k--) { 151 | options['publicKey']['allowCredentials'][k]['id'] = this.base64ToUint8array(options['publicKey']['allowCredentials'][k]['id']); 152 | } 153 | 154 | const result = await navigator.credentials.get(options); 155 | // if (!result) return false; 156 | console.log(result); 157 | // Make result JSON friendly. 158 | var output = { 159 | 'id': result.id.replace(/-/g, '+').replace(/_/g, '/'), // Use normal base64, not base64url (rfc4648) 160 | 'type': result.type, 161 | 'response': { 162 | 'clientDataJSON': this.bufferToBase64(result.response.clientDataJSON), 163 | 'authenticatorData': this.bufferToBase64(result.response.authenticatorData), 164 | 'signature': this.bufferToBase64(result.response.signature) 165 | } 166 | }; 167 | 168 | // Complete 169 | const verifyRequest = await fetch(this.urls.verify, { 170 | 'method': 'POST', 171 | 'headers': { 172 | 'Accept': 'application/json', 173 | 'Content-Type': 'application/json' 174 | }, 175 | 'body': JSON.stringify(output) 176 | }); 177 | 178 | const response = await verifyRequest.json(); 179 | 180 | if (response && response.result > -1) { 181 | console.info('passkey verification successful', response.result); 182 | window.location.href = this.urls.home; 183 | } else { 184 | console.error('passkey verification failed', response.result); 185 | } 186 | } 187 | 188 | async revoke (event) { 189 | if (!this.support) return false; 190 | 191 | if (window.confirm("Really remove your passkey?")) { 192 | const request = await fetch(this.urls.revoke, { 193 | 'method': 'GET', 194 | 'headers': { 195 | 'Accept': 'application/json', 196 | 'Content-Type': 'application/json' 197 | } 198 | }); 199 | const response = await request.json(); 200 | 201 | if (response.result > -1) { 202 | console.info('passkey removed from database'); 203 | this.dom.status.innerText = 'Passkey was removed'; 204 | } else { 205 | console.error('an error occurred while trying to remove the passkey!', response); 206 | } 207 | } 208 | } 209 | 210 | // helpers 211 | 212 | uint8arrayToBase64(array) { // https://stackoverflow.com/a/12713326/6632 213 | return window.btoa(String.fromCharCode.apply(null, array)); 214 | } 215 | 216 | uint8arrayToHex(array) { // https://stackoverflow.com/a/40031979/6632 217 | return Array.prototype.map.call(array, function (x) { 218 | return ('00' + x.toString(16)).slice(-2); 219 | }).join(''); 220 | } 221 | 222 | uint8arrayToBuffer(array) { // https://stackoverflow.com/a/54646864/6632 223 | return array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset) 224 | } 225 | 226 | bufferToBase64(buffer) { 227 | return this.uint8arrayToBase64(new Uint8Array(buffer)); 228 | } 229 | 230 | bufferToHex(buffer) { 231 | return this.uint8arrayToHex(new Uint8Array(buffer)); 232 | } 233 | 234 | base64ToUint8array(base64) { // https://stackoverflow.com/a/21797381/6632 235 | var binary = window.atob(base64), 236 | array = new Uint8Array(new ArrayBuffer(binary.length)); 237 | 238 | for (var k = (binary.length - 1); k >= 0; k--) { 239 | array[k] = binary.charCodeAt(k); 240 | } 241 | 242 | return array; 243 | } 244 | 245 | textToUint8array(text) { 246 | if (!this.textEncoder) this.textEncoder = new TextEncoder(); 247 | return this.textEncoder.encode(text); 248 | } 249 | } 250 | 251 | export { PK } 252 | -------------------------------------------------------------------------------- /js/squircle.js: -------------------------------------------------------------------------------- 1 | const drawSquircle = (ctx, geom, radius, smooth, lineWidth, color) => { 2 | const defaultFill = color; 3 | const lineWidthOffset = lineWidth / 2; 4 | // OPEN LEFT-TOP CORNER 5 | ctx.beginPath(); 6 | ctx.lineTo(radius, lineWidthOffset); 7 | // TOP-RIGHT CORNER 8 | ctx.lineTo(geom.width - radius, lineWidthOffset); 9 | ctx.bezierCurveTo( 10 | geom.width - radius / smooth, 11 | lineWidthOffset, // first bezier point 12 | geom.width - lineWidthOffset, 13 | radius / smooth, // second bezier point 14 | geom.width - lineWidthOffset, 15 | radius // last connect point 16 | ); 17 | // BOTTOM-RIGHT CORNER 18 | ctx.lineTo(geom.width - lineWidthOffset, geom.height - radius); 19 | ctx.bezierCurveTo( 20 | geom.width - lineWidthOffset, 21 | geom.height - radius / smooth, // first bezier point 22 | geom.width - radius / smooth, 23 | geom.height - lineWidthOffset, // second bezier point 24 | geom.width - radius, 25 | geom.height - lineWidthOffset // last connect point 26 | ); 27 | // BOTTOM-LEFT CORNER 28 | ctx.lineTo(radius, geom.height - lineWidthOffset); 29 | ctx.bezierCurveTo( 30 | radius / smooth, 31 | geom.height - lineWidthOffset, // first bezier point 32 | lineWidthOffset, 33 | geom.height - radius / smooth, // second bezier point 34 | lineWidthOffset, 35 | geom.height - radius // last connect point 36 | ); 37 | // CLOSE LEFT-TOP CORNER 38 | ctx.lineTo(lineWidthOffset, radius); 39 | ctx.bezierCurveTo( 40 | lineWidthOffset, 41 | radius / smooth, // first bezier point 42 | radius / smooth, 43 | lineWidthOffset, // second bezier point 44 | radius, 45 | lineWidthOffset // last connect point 46 | ); 47 | ctx.closePath(); 48 | 49 | if (lineWidth) { 50 | // console.log(lineWidth); 51 | ctx.strokeStyle = defaultFill; 52 | ctx.lineWidth = lineWidth; 53 | ctx.stroke(); 54 | } else { 55 | ctx.fillStyle = defaultFill; 56 | ctx.fill(); 57 | } 58 | }; 59 | 60 | if (typeof registerPaint !== "undefined") { 61 | class SquircleClass { 62 | static get contextOptions() { 63 | return { alpha: true }; 64 | } 65 | static get inputProperties() { 66 | return [ 67 | "--squircle-radius", 68 | "--squircle-smooth", 69 | "--squircle-outline", 70 | "--squircle-fill", 71 | "--squircle-ratio", 72 | ]; 73 | } 74 | 75 | paint(ctx, geom, properties) { 76 | const customRatio = properties.get("--squircle-ratio"); 77 | const smoothRatio = 10; 78 | const distanceRatio = parseFloat(customRatio) 79 | ? parseFloat(customRatio) 80 | : 1.8; 81 | const squircleSmooth = parseFloat( 82 | properties.get("--squircle-smooth") * smoothRatio 83 | ); 84 | const squircleRadius = 85 | parseInt(properties.get("--squircle-radius"), 10) * distanceRatio; 86 | const squrcleOutline = parseFloat( 87 | properties.get("--squircle-outline"), 88 | 10 89 | ); 90 | const squrcleColor = properties 91 | .get("--squircle-fill") 92 | .toString() 93 | .replace(/\s/g, ""); 94 | 95 | const isSmooth = () => { 96 | if (typeof properties.get("--squircle-smooth")[0] !== "undefined") { 97 | if (squircleSmooth === 0) { 98 | return 1; 99 | } 100 | return squircleSmooth; 101 | } else { 102 | return 10; 103 | } 104 | }; 105 | 106 | const isOutline = () => { 107 | if (squrcleOutline) { 108 | return squrcleOutline; 109 | } else { 110 | return 0; 111 | } 112 | }; 113 | 114 | const isColor = () => { 115 | if (squrcleColor) { 116 | return squrcleColor; 117 | } else { 118 | return "#f45"; 119 | } 120 | }; 121 | 122 | if (squircleRadius < geom.width / 2 && squircleRadius < geom.height / 2) { 123 | drawSquircle( 124 | ctx, 125 | geom, 126 | squircleRadius, 127 | isSmooth(), 128 | isOutline(), 129 | isColor() 130 | ); 131 | } else { 132 | drawSquircle( 133 | ctx, 134 | geom, 135 | Math.min(geom.width / 2, geom.height / 2), 136 | isSmooth(), 137 | isOutline(), 138 | isColor() 139 | ); 140 | } 141 | } 142 | } 143 | 144 | // eslint-disable-next-line no-undef 145 | registerPaint("squircle", SquircleClass); 146 | } -------------------------------------------------------------------------------- /lib/activitypub-actor.php: -------------------------------------------------------------------------------- 1 | 'sha512', 25 | 'private_key_bits' => 4096, 26 | 'private_key_type' => OPENSSL_KEYTYPE_RSA, 27 | ]); 28 | openssl_pkey_export($rsa, $private_key); 29 | $public_key = openssl_pkey_get_details($rsa)['key']; 30 | 31 | file_put_contents(ROOT.DS.'keys'.DS.'id_rsa', $private_key); 32 | file_put_contents(ROOT.DS.'keys'.DS.'id_rsa.pub', $public_key); 33 | } else { 34 | $public_key = file_get_contents(ROOT.DS.'keys'.DS.'id_rsa.pub'); 35 | } 36 | */ 37 | 38 | $profile_image = $config['url'].'/favicon-large.png'; 39 | $profile_image_type = 'image/png'; 40 | if(!empty($config['site_image'])) { 41 | $profile_image = $config['site_image']; 42 | $profile_image_type = image_type_to_mime_type(exif_imagetype($profile_image)); 43 | } 44 | 45 | if(strpos($_SERVER['HTTP_ACCEPT'], 'application/activity+json') !== false): 46 | 47 | header('Content-Type: application/ld+json'); 48 | 49 | ?>{ 50 | "@context": [ 51 | "https://www.w3.org/ns/activitystreams", 52 | "https://w3id.org/security/v1" 53 | ], 54 | "id": "= $config['url'] ?>/actor", 55 | "type": "Person", 56 | "name": "= trim($config['site_title']) ?>", 57 | "summary": "= trim($config['site_claim']) ?>", 58 | "preferredUsername": "= ltrim($config['microblog_account'], '@') ?>", 59 | "manuallyApprovesFollowers": false, 60 | "discoverable": true, 61 | "publishedDate": "2023-01-01T00:00:00Z", 62 | "icon": { 63 | "url": "= $profile_image ?>", 64 | "mediaType": "= $profile_image_type ?>", 65 | "type": "Image" 66 | }, 67 | "inbox": "= $config['url'] ?>/inbox", 68 | "outbox": "= $config['url'] ?>/outbox", 69 | "followers": "= $config['url'] ?>/followers", 70 | "publicKey": { 71 | "id": "= $config['url'] ?>/actor#main-key", 72 | "owner": "= $config['url'] ?>/actor", 73 | "publicKeyPem": "= preg_replace('/\n/', '\n', $public_key) ?>" 74 | } 75 | } 76 | 83 | -------------------------------------------------------------------------------- /lib/activitypub-followers.php: -------------------------------------------------------------------------------- 1 | prepare('SELECT COUNT(id) as total FROM followers WHERE follower_actor IS NOT NULL'); 7 | $statement->execute(); 8 | $followers_total = $statement->fetchAll(PDO::FETCH_ASSOC); 9 | $followers_total = (!empty($followers_total)) ? $followers_total[0]['total'] : 0; 10 | 11 | if(!isset($_GET['page'])): 12 | 13 | $output = [ 14 | '@context' => 'https://www.w3.org/ns/activitystreams', 15 | 'id' => $config['url'].'/followers', 16 | 'type' => 'OrderedCollection', 17 | 'totalItems' => $followers_total, 18 | 'first' => $config['url'].'/followers?page=1', 19 | ]; 20 | 21 | header('Content-Type: application/ld+json'); 22 | echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 23 | else: 24 | 25 | // get items 26 | $items_per_page = 12; // mastodon default? 27 | 28 | // pagination 29 | $current_page = (isset($_GET['page']) && is_numeric($_GET['page'])) ? (int) $_GET['page'] : 1; 30 | $total_pages = ceil($followers_total / $items_per_page); 31 | $offset = ($current_page-1)*$items_per_page; 32 | 33 | if($current_page < 1 || $current_page > $total_pages) { 34 | http_response_code(404); 35 | header('Content-Type: application/ld+json'); 36 | die('{}'); 37 | } 38 | 39 | $statement = $db->prepare('SELECT follower_actor FROM followers WHERE follower_actor IS NOT NULL ORDER BY follower_added ASC LIMIT :limit OFFSET :page'); 40 | $statement->bindValue(':limit', $items_per_page, PDO::PARAM_INT); 41 | $statement->bindValue(':page', $offset, PDO::PARAM_INT); 42 | $statement->execute(); 43 | $followers = $statement->fetchAll(PDO::FETCH_ASSOC); 44 | 45 | $ordered_items = []; 46 | if(!empty($followers)) { 47 | $ordered_items = array_column($followers, 'follower_actor'); 48 | } 49 | 50 | $output = [ 51 | '@context' => 'https://www.w3.org/ns/activitystreams', 52 | 'id' => $config['url'].'/followers?page='.$current_page, 53 | 'type' => 'OrderedCollectionPage', 54 | 'totalItems' => $followers_total, 55 | 'partOf' => $config['url'].'/followers' 56 | ]; 57 | 58 | if($current_page > 1) { 59 | $output['prev'] = $config['url'].'/followers?page='.($current_page-1); 60 | } 61 | 62 | if($current_page < $total_pages) { 63 | $output['next'] = $config['url'].'/followers?page='.($current_page+1); 64 | } 65 | 66 | $output['orderedItems'] = $ordered_items; 67 | 68 | header('Content-Type: application/ld+json'); 69 | echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 70 | endif; 71 | -------------------------------------------------------------------------------- /lib/activitypub-functions.php: -------------------------------------------------------------------------------- 1 | $algo, 18 | 'private_key_bits' => $bits, 19 | 'private_key_type' => $key_type 20 | ]); 21 | openssl_pkey_export($rsa, $private_key); 22 | $public_key = openssl_pkey_get_details($rsa)['key']; 23 | $created = time(); 24 | 25 | try { 26 | $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)'); 27 | 28 | $statement->bindValue(':private', $private_key, PDO::PARAM_STR); 29 | $statement->bindValue(':public', $public_key, PDO::PARAM_STR); 30 | $statement->bindValue(':algo', $algo, PDO::PARAM_STR); 31 | $statement->bindValue(':bits', $bits, PDO::PARAM_INT); 32 | $statement->bindValue(':type', mb_strtolower($type), PDO::PARAM_STR); 33 | $statement->bindValue(':created', $created, PDO::PARAM_INT); 34 | 35 | $statement->execute(); 36 | 37 | } catch(PDOException $e) { 38 | ap_log('ERROR', $e->getMessage()); 39 | return false; 40 | } 41 | 42 | if($db->lastInsertId() > 0) { 43 | return [ 44 | 'id' => $db->lastInsertId(), 45 | 'key_private' => $private_key, 46 | 'key_public' => $public_key, 47 | 'key_algo' => $algo, 48 | 'key_bits' => $bits, 49 | 'key_type' => mb_strtolower($type), 50 | 'key_created' => $created 51 | ]; 52 | } 53 | return false; 54 | } 55 | 56 | function activitypub_get_key($type = 'public') { 57 | global $db; 58 | 59 | $sql = ''; 60 | 61 | if($type == 'public') { 62 | $sql = 'SELECT key_public FROM keys ORDER BY key_created DESC LIMIT 1'; 63 | } elseif($type == 'private') { 64 | $sql = 'SELECT key_private FROM keys ORDER BY key_created DESC LIMIT 1'; 65 | } else { 66 | $sql = 'SELECT * FROM keys ORDER BY key_created DESC LIMIT 1'; 67 | } 68 | 69 | try { 70 | $statement = $db->prepare($sql); 71 | 72 | $statement->execute(); 73 | } catch(PDOException $e) { 74 | ap_log('ERROR', $e->getMessage()); 75 | return false; 76 | } 77 | 78 | $key = $statement->fetch(PDO::FETCH_ASSOC); 79 | 80 | if(!empty($key)) { 81 | if($type == 'public') { 82 | return $key['key_public']; 83 | } elseif($type == 'private') { 84 | return $key['key_private']; 85 | } else { 86 | return $key; 87 | } 88 | } 89 | 90 | return false; 91 | } 92 | 93 | function activitypub_get_actor_url($handle, $full_profile = false) { 94 | list($user, $host) = explode('@', ltrim($handle, '@')); 95 | 96 | $ch = curl_init(); 97 | 98 | $url = sprintf('https://%s/.well-known/webfinger?resource=acct%%3A%s', $host, urlencode($user.'@'.$host)); 99 | 100 | curl_setopt($ch, CURLOPT_URL, $url); 101 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 102 | 103 | $server_response = curl_exec($ch); 104 | // ap_log('WEBFINGER RESPONSE', $server_response); 105 | 106 | curl_close($ch); 107 | 108 | $profile = json_decode($server_response, true); 109 | if($full_profile) { 110 | return $profile; 111 | } 112 | 113 | // make this more robust by iterating over links where href = self? 114 | return $profile['links'][1]['href']; 115 | } 116 | 117 | function activitypub_get_actor_data($actor_url='') { 118 | if(empty($actor_url)) return false; 119 | 120 | $opts = [ 121 | "http" => [ 122 | "method" => "GET", 123 | "header" => join("\r\n", [ 124 | "Accept: application/activity+json", 125 | "Content-type: application/activity+json", 126 | ]) 127 | ] 128 | ]; 129 | 130 | $context = stream_context_create($opts); 131 | 132 | $file = @file_get_contents($actor_url, false, $context); // fix? 133 | 134 | if(!empty($file)) { 135 | return json_decode($file, true); 136 | } 137 | 138 | return false; 139 | } 140 | 141 | function activitypub_plaintext($path, $host, $date, $digest, $type='application/activity+json'): string { 142 | $plaintext = sprintf( 143 | "(request-target): post %s\nhost: %s\ndate: %s\ndigest: %s\ncontent-type: %s", 144 | $path, 145 | $host, 146 | $date, 147 | $digest, 148 | $type 149 | ); 150 | 151 | // ap_log('PLAINTEXT', $plaintext); 152 | 153 | return $plaintext; 154 | } 155 | 156 | function activitypub_digest(string $data): string { 157 | return sprintf('SHA-256=%s', base64_encode(hash('sha256', $data, true))); 158 | } 159 | 160 | function activitypub_sign($path, $host, $date, $digest): string { 161 | $private_key = activitypub_get_key('private'); 162 | 163 | openssl_sign(activitypub_plaintext($path, $host, $date, $digest), $signature, openssl_get_privatekey($private_key), OPENSSL_ALGO_SHA256); 164 | 165 | return $signature; 166 | } 167 | 168 | function activitypub_verify(string $signature, string $pubkey, string $plaintext): bool { 169 | return openssl_verify($plaintext, base64_decode($signature), $pubkey, OPENSSL_ALGO_SHA256); 170 | } 171 | 172 | function activitypub_send_request($host, $path, $data): void { 173 | global $config; 174 | 175 | $encoded = json_encode($data); 176 | 177 | $date = gmdate('D, d M Y H:i:s T', time()); 178 | $digest = activitypub_digest($encoded); 179 | 180 | $signature = activitypub_sign( 181 | $path, 182 | $host, 183 | $date, 184 | $digest 185 | ); 186 | 187 | $signature_header = sprintf( 188 | 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="%s"', 189 | $config['url'].'/actor#main-key', 190 | base64_encode($signature) 191 | ); 192 | 193 | // DEBUG 194 | $fp = fopen(ROOT.DS.'inbox-log.txt', 'a'); 195 | 196 | $curl_headers = [ 197 | 'Content-Type: application/activity+json', 198 | 'Date: ' . $date, 199 | 'Signature: ' . $signature_header, 200 | 'Digest: ' . $digest 201 | ]; 202 | 203 | ap_log('SEND MESSAGE', json_encode([$data, $curl_headers], JSON_PRETTY_PRINT)); 204 | 205 | $ch = curl_init(); 206 | 207 | curl_setopt($ch, CURLOPT_URL, sprintf('https://%s%s', $host, $path)); 208 | curl_setopt($ch, CURLOPT_POST, 1); 209 | curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded); 210 | curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_headers); 211 | curl_setopt($ch, CURLOPT_HEADER, 1); 212 | curl_setopt($ch, CURLOPT_VERBOSE, false); 213 | curl_setopt($ch, CURLOPT_STDERR, $fp); 214 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 215 | 216 | $server_output = curl_exec($ch); 217 | 218 | curl_close($ch); 219 | fclose($fp); 220 | 221 | ap_log('SERVER RESPONSE', $server_output); 222 | } 223 | 224 | function activitypub_activity_from_post($post, $json=false) { 225 | global $config; 226 | 227 | if(empty($post)) return false; 228 | 229 | $output = [ 230 | '@context' => 'https://www.w3.org/ns/activitystreams', 231 | 232 | 'id' => $config['url'].'/'.$post['id'].'/json', 233 | 'type' => 'Create', 234 | 'actor' => $config['url'].'/actor', 235 | 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 236 | 'cc' => [$config['url'].'/followers'], 237 | 'object' => [ 238 | 'id' => $config['url'].'/'.$post['id'], 239 | 'type' => 'Note', 240 | 'published' => gmdate('Y-m-d\TH:i:s\Z', $post['post_timestamp']), 241 | 'attributedTo' => $config['url'].'/actor', 242 | 'content' => filter_tags($post['post_content']), 243 | 'to' => ['https://www.w3.org/ns/activitystreams#Public'] 244 | ] 245 | ]; 246 | 247 | $attachments = db_get_attached_files($post['id']); 248 | 249 | if(!empty($attachments) && !empty($attachments[$post['id']])) { 250 | $output['object']['attachment'] = []; 251 | 252 | foreach ($attachments[$post['id']] as $key => $a) { 253 | if(strpos($a['file_mime_type'], 'image') !== 0) continue; // skip non-image files 254 | 255 | $url = $config['url'] .'/'. get_file_path($a); 256 | 257 | $output['object']['attachment'][] = [ 258 | 'type' => 'Image', 259 | 'mediaType' => $a['file_mime_type'], 260 | 'url' => $url, 261 | 'name' => null 262 | ]; 263 | } 264 | } 265 | 266 | if ($json) { 267 | return json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 268 | } 269 | 270 | return $output; 271 | } 272 | 273 | function activitypub_notify_followers($post_id): void { 274 | global $db; 275 | // todo: make this a queue 276 | 277 | // API ENDPOINTS 278 | // https://mastodon.social/api/v2/instance 279 | 280 | // users without shared inbox 281 | $statement = $db->prepare('SELECT * FROM followers WHERE follower_shared_inbox IS NULL'); 282 | $statement->execute(); 283 | $followers = $statement->fetchAll(PDO::FETCH_ASSOC); 284 | 285 | // users with shared inbox 286 | $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'); 287 | $statement->execute(); 288 | $shared_inboxes = $statement->fetchAll(PDO::FETCH_ASSOC); 289 | 290 | // get the activity data, eg. https://microblog.oelna.de/11/json 291 | $post = db_select_post($post_id); 292 | $post_activity = activitypub_activity_from_post($post); 293 | 294 | $update = [ 295 | 'id' => null, 296 | 'inbox' => null, 297 | 'actor' => null 298 | ]; 299 | 300 | // prepare db for possible updates 301 | $statement = $db->prepare('UPDATE followers SET follower_inbox = :inbox, follower_actor = :actor WHERE id = :id'); 302 | $statement->bindParam(':id', $update['id'], PDO::PARAM_INT); 303 | $statement->bindParam(':inbox', $update['inbox'], PDO::PARAM_STR); 304 | $statement->bindParam(':actor', $update['actor'], PDO::PARAM_STR); 305 | 306 | // iterate over shared inboxes to deliver those quickly 307 | foreach($shared_inboxes as $inbox) { 308 | $info = parse_url($inbox['shared_inbox']); 309 | // ap_log('SHARED_INBOX_DELIVERY', json_encode([$inbox, $info, $post_activity], JSON_PRETTY_PRINT)); 310 | // todo: verify we don't need to handle single usernames here 311 | // using the followers URL as CC is enough? 312 | activitypub_send_request($info['host'], $info['path'], $post_activity); 313 | } 314 | 315 | // iterate over followers and send create activity 316 | foreach($followers as $follower) { 317 | 318 | // retrieve actor info, if missing (is this necessary?) 319 | if(empty($follower['follower_inbox'])) { 320 | 321 | $actor_url = activitypub_get_actor_url($follower['follower_name'].'@'.$follower['follower_host']); 322 | if (empty($actor_url)) continue; 323 | 324 | $actor_data = activitypub_get_actor_data($actor_url); 325 | if (empty($actor_data) || empty($actor_data['inbox'])) continue; 326 | 327 | // cache this info 328 | $update['id'] = $follower['id']; 329 | $update['inbox'] = $actor_data['inbox']; 330 | $update['actor'] = $actor_url; 331 | 332 | try { 333 | $statement->execute(); 334 | } catch(PDOException $e) { 335 | continue; 336 | } 337 | 338 | $follower['follower_inbox'] = $actor_data['inbox']; 339 | } 340 | 341 | $info = parse_url($follower['follower_inbox']); 342 | 343 | activitypub_send_request($info['host'], $info['path'], $post_activity); 344 | 345 | ap_log('SENDING TO', json_encode([$info['host'], $info['path']], JSON_PRETTY_PRINT)); 346 | } 347 | } 348 | 349 | function activitypub_post_from_url($url="") { 350 | // todo: this should be more robust and conform to url scheme on this site 351 | 352 | $path = parse_url($url, PHP_URL_PATH); 353 | 354 | $items = explode('/', $path); 355 | $post_id = end($items); 356 | 357 | if (is_numeric($post_id)) { 358 | return (int) $post_id; 359 | } 360 | 361 | return false; 362 | } 363 | 364 | function activitypub_do($type, $user, $host, $post_id) { 365 | if (empty($type)) return false; 366 | 367 | global $db; 368 | 369 | $activity = [ 370 | 'actor_name' => $user, 371 | 'actor_host' => $host, 372 | 'type' => (mb_strtolower($type) == 'like') ? 'like' : 'announce', 373 | 'object_id' => (int) $post_id, 374 | 'updated' => time() 375 | ]; 376 | 377 | try { 378 | $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)'); 379 | 380 | $statement->bindValue(':actor_name', $activity['actor_name'], PDO::PARAM_STR); 381 | $statement->bindValue(':actor_host', $activity['actor_host'], PDO::PARAM_STR); 382 | $statement->bindValue(':type', $activity['type'], PDO::PARAM_STR); 383 | $statement->bindValue(':object_id', $activity['object_id'], PDO::PARAM_INT); 384 | $statement->bindValue(':updated', $activity['updated'], PDO::PARAM_INT); 385 | 386 | $statement->execute(); 387 | 388 | } catch(PDOException $e) { 389 | print 'Exception : '.$e->getMessage(); 390 | ap_log('ERROR', $e->getMessage()); 391 | return false; 392 | } 393 | 394 | ap_log('INSERTED ACTIVITY', json_encode([$activity, $db->lastInsertId()], JSON_PRETTY_PRINT)); 395 | return $db->lastInsertId(); 396 | } 397 | 398 | function activitypub_undo($type, $user, $host, $post_id) { 399 | if (empty($type)) return false; 400 | 401 | global $db; 402 | 403 | $activity = [ 404 | 'actor_name' => $user, 405 | 'actor_host' => $host, 406 | 'type' => (mb_strtolower($type) == 'like') ? 'like' : 'announce', // todo: make this safer 407 | 'object_id' => (int) $post_id 408 | ]; 409 | try { 410 | $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'); 411 | $statement->bindValue(':actor_name', $activity['actor_name'], PDO::PARAM_STR); 412 | $statement->bindValue(':actor_host', $activity['actor_host'], PDO::PARAM_STR); 413 | $statement->bindValue(':type', $activity['type'], PDO::PARAM_STR); 414 | $statement->bindValue(':object_id', $activity['object_id'], PDO::PARAM_INT); 415 | 416 | $statement->execute(); 417 | } catch(PDOException $e) { 418 | print 'Exception : '.$e->getMessage(); 419 | ap_log('ERROR', $e->getMessage()); 420 | return false; 421 | } 422 | 423 | ap_log('SQL DELETE', json_encode([$statement->rowCount()])); 424 | return true; 425 | return $statement->rowCount(); 426 | } 427 | 428 | function activitypub_update_post($post_id) { 429 | // https://www.w3.org/TR/activitypub/#update-activity-inbox 430 | } 431 | 432 | function activitypub_delete_user($name, $host) { 433 | if(empty($name) || empty($host)) return false; 434 | 435 | global $db; 436 | 437 | // delete all records of user as follower 438 | try { 439 | $statement = $db->prepare('DELETE FROM followers WHERE follower_name = :actor_name AND follower_host = :actor_host'); 440 | $statement->bindValue(':actor_name', $name, PDO::PARAM_STR); 441 | $statement->bindValue(':actor_host', $host, PDO::PARAM_STR); 442 | 443 | $statement->execute(); 444 | } catch(PDOException $e) { 445 | print 'Exception : '.$e->getMessage(); 446 | ap_log('ERROR', $e->getMessage()); 447 | return false; 448 | } 449 | 450 | // remove likes and boosts 451 | try { 452 | $statement = $db->prepare('DELETE FROM activities WHERE activity_actor_name = :actor_name AND activity_actor_host = :actor_host'); 453 | $statement->bindValue(':actor_name', $name, PDO::PARAM_STR); 454 | $statement->bindValue(':actor_host', $host, PDO::PARAM_STR); 455 | 456 | $statement->execute(); 457 | } catch(PDOException $e) { 458 | print 'Exception : '.$e->getMessage(); 459 | ap_log('ERROR', $e->getMessage()); 460 | return false; 461 | } 462 | 463 | return true; 464 | } 465 | 466 | function activitypub_get_post_stats($type="like", $post_id=null) { 467 | global $db; 468 | if(empty($db)) return false; 469 | if(empty($post_id)) return false; 470 | 471 | // normalize type input, liberally 472 | if(in_array($type, ['announce', 'announced', 'boost', 'boosts', 'boosted'])) $type = 'announce'; 473 | if($type == 'both' || $type == 'all') $type = 'both'; 474 | if($type !== 'both' && $type !== 'announce') $type = 'like'; 475 | 476 | $type_clause = 'activity_type = "like"'; 477 | if($type == 'both') { 478 | $type_clause = '(activity_type = "like" OR activity_type = "announce")'; 479 | } elseif($type == 'announce') { 480 | $type_clause = 'activity_type = "announce"'; 481 | } 482 | 483 | $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'; 484 | 485 | try { 486 | $statement = $db->prepare($sql); 487 | $statement->bindValue(':post_id', (int) $post_id, PDO::PARAM_INT); 488 | $statement->execute(); 489 | $rows = $statement->fetchAll(PDO::FETCH_ASSOC); 490 | } catch(PDOException $e) { 491 | print 'Exception : '.$e->getMessage(); 492 | return false; 493 | } 494 | 495 | $return = [ 496 | 'announce' => 0, 497 | 'like' => 0 498 | ]; 499 | 500 | if(!empty($rows)) { 501 | foreach ($rows as $row) { 502 | if($row['activity_type'] == 'announce') { 503 | $return['announce'] = (int) $row['amount']; 504 | } else if($row['activity_type'] == 'like') { 505 | $return['like'] = (int) $row['amount']; 506 | } 507 | } 508 | } 509 | 510 | if($type == 'both') { 511 | return $return; 512 | } elseif($type == 'announce') { 513 | unset($return['like']); 514 | return $return; 515 | } else { 516 | unset($return['announce']); 517 | return $return; 518 | } 519 | 520 | return $return; 521 | } 522 | -------------------------------------------------------------------------------- /lib/activitypub-inbox.php: -------------------------------------------------------------------------------- 1 | $inbox['host'], 23 | 'path' => $inbox['path'], 24 | 'digest' => $_SERVER['HTTP_DIGEST'], 25 | 'date' => $_SERVER['HTTP_DATE'], 26 | 'length' => $_SERVER['CONTENT_LENGTH'], 27 | 'type' => $_SERVER['CONTENT_TYPE'] 28 | ]; 29 | 30 | header('Content-Type: application/ld+json'); 31 | 32 | ap_log('POSTDATA', $postdata); 33 | // ap_log('REQUEST', json_encode($request)); 34 | 35 | // verify message digest 36 | $digest_verify = activitypub_digest($postdata); 37 | if($digest_verify === $request['digest']) { 38 | // ap_log('DIGEST', 'Passed verification for ' . $digest_verify); 39 | } else { 40 | ap_log('ERROR', json_encode(['digest verification failed!', $request['digest'], $digest_verify], JSON_PRETTY_PRINT)); 41 | } 42 | 43 | // GET ACTOR DETAILS 44 | if(!empty($data) && !empty($data['actor'])) { 45 | $actor = activitypub_get_actor_data($data['actor']); 46 | 47 | if(!empty($actor)) { 48 | $actor_key = $actor['publicKey']; 49 | $info = parse_url($actor['inbox']); 50 | } else { 51 | exit('could not parse actor data'); 52 | } 53 | } else { 54 | exit('no actor provided'); 55 | } 56 | 57 | $signature = []; 58 | $signature_string = $_SERVER['HTTP_SIGNATURE']; 59 | $parts = explode(',', stripslashes($signature_string)); 60 | foreach ($parts as $part) { 61 | $part = trim($part, '"'); 62 | list($k, $v) = explode('=', $part); 63 | $signature[$k] = trim($v, '"'); 64 | } 65 | 66 | // ap_log('SIGNATURE', json_encode($signature)); 67 | // ap_log('ACTOR', json_encode($actor)); 68 | // ap_log('PUBLIC KEY', str_replace("\n", '\n', $actor_key['publicKeyPem'])); 69 | 70 | $plaintext = activitypub_plaintext($request['path'], $request['host'], $request['date'], $request['digest'], $request['type']); 71 | 72 | // verify request signature 73 | $result = activitypub_verify($signature['signature'], $actor_key['publicKeyPem'], $plaintext); 74 | 75 | if($result != 1) { 76 | ap_log('REQUEST', json_encode($request)); 77 | ap_log('SIGNATURE', json_encode($signature)); 78 | ap_log('PUBLIC KEY', str_replace("\n", '\n', $actor_key['publicKeyPem'])); 79 | ap_log('RESULT', json_encode([$result, $plaintext], JSON_PRETTY_PRINT)); 80 | ap_log('SSL ERROR', 'message signature did not match'); 81 | exit('message signature did not match'); 82 | } else { 83 | ap_log('SSL OKAY', json_encode([$request, $signature, $result, $plaintext, $actor_key['publicKeyPem']], JSON_PRETTY_PRINT)); 84 | } 85 | 86 | // message signature was ok, now handle the request 87 | 88 | if(!empty($data['type'])) { 89 | if(mb_strtolower($data['type']) == 'follow') { 90 | // follow 91 | 92 | $accept_data = [ 93 | '@context' => 'https://www.w3.org/ns/activitystreams', 94 | 'id' => sprintf('%s/activity/%s', $config['url'], uniqid()), 95 | 'type' => 'Accept', 96 | 'actor' => sprintf('%s/actor', $config['url']), 97 | 'object' => $data 98 | ]; 99 | 100 | // send back Accept activity 101 | activitypub_send_request($info['host'], $info['path'], $accept_data); 102 | 103 | $now = time(); 104 | $follower = [ 105 | 'name' => $actor['preferredUsername'], 106 | 'host' => $info['host'], 107 | 'actor' => $data['actor'], 108 | 'inbox' => $actor['inbox'], 109 | 'added' => time() 110 | ]; 111 | try { 112 | $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)'); 113 | 114 | $statement->bindValue(':follower_name', $follower['name'], PDO::PARAM_STR); 115 | $statement->bindValue(':follower_host', $follower['host'], PDO::PARAM_STR); 116 | $statement->bindValue(':follower_actor', $follower['actor'], PDO::PARAM_STR); 117 | $statement->bindValue(':follower_inbox', $follower['inbox'], PDO::PARAM_STR); 118 | $statement->bindValue(':follower_added', $follower['added'], PDO::PARAM_INT); 119 | 120 | // store shared inbox if possible 121 | if(!empty($actor['endpoints']) && !empty($actor['endpoints']['sharedInbox'])) { 122 | $statement->bindValue(':follower_shared_inbox', $actor['endpoints']['sharedInbox'], PDO::PARAM_STR); 123 | } else { 124 | $statement->bindValue(':follower_shared_inbox', null, PDO::PARAM_NULL); 125 | } 126 | 127 | $statement->execute(); 128 | 129 | } catch(PDOException $e) { 130 | print 'Exception : '.$e->getMessage(); 131 | ap_log('ERROR FOLLOWING', $e->getMessage()); 132 | } 133 | 134 | ap_log('FOLLOW', json_encode([$actor['inbox'], $info, $accept_data], JSON_PRETTY_PRINT)); 135 | 136 | } elseif(mb_strtolower($data['type']) == 'like') { 137 | // like/favorite 138 | ap_log('LIKE', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); 139 | $post_id = activitypub_post_from_url($data['object']); 140 | activitypub_do('like', $actor['preferredUsername'], $info['host'], $post_id); 141 | } elseif(mb_strtolower($data['type']) == 'announce') { 142 | // boost 143 | ap_log('ANNOUNCE/BOOST', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); 144 | $post_id = activitypub_post_from_url($data['object']); 145 | activitypub_do('announce', $actor['preferredUsername'], $info['host'], $post_id); 146 | } elseif(mb_strtolower($data['type']) == 'undo') { 147 | if(mb_strtolower($data['object']['type']) == 'follow') { 148 | // undo follow 149 | 150 | ap_log('UNDO FOLLOW', json_encode([$plaintext])); 151 | 152 | // remove from db 153 | $follower = [ 154 | 'name' => $actor['preferredUsername'], 155 | 'host' => $info['host'] 156 | ]; 157 | 158 | try { 159 | $statement = $db->prepare('DELETE FROM followers WHERE follower_name = :name AND follower_host = :host'); 160 | $statement->bindValue(':name', $follower['name'], PDO::PARAM_STR); 161 | $statement->bindValue(':host', $follower['host'], PDO::PARAM_STR); 162 | 163 | $statement->execute(); 164 | } catch(PDOException $e) { 165 | print 'Exception : '.$e->getMessage(); 166 | ap_log('ERROR UNFOLLOWING', $e->getMessage()); 167 | } 168 | 169 | } elseif(mb_strtolower($data['object']['type']) == 'like') { 170 | // undo like 171 | $post_id = activitypub_post_from_url($data['object']['object']); 172 | activitypub_undo('like', $actor['preferredUsername'], $info['host'], $post_id); 173 | ap_log('UNDO LIKE', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); 174 | } elseif(mb_strtolower($data['object']['type']) == 'announce') { 175 | // undo boost 176 | $post_id = activitypub_post_from_url($data['object']['object']); 177 | activitypub_undo('announce', $actor['preferredUsername'], $info['host'], $post_id); 178 | ap_log('UNDO ANNOUNCE/BOOST', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); 179 | } 180 | } elseif(mb_strtolower($data['type']) == 'delete') { 181 | // user is to be deleted and all references removed or replaced by Tombstone 182 | // https://www.w3.org/TR/activitypub/#delete-activity-inbox 183 | ap_log('DELETE 1', json_encode(['trying to delete', $data])); 184 | activitypub_delete_user($actor['preferredUsername'], $info['host']); 185 | ap_log('DELETE 2', json_encode([$actor['preferredUsername'], $info['host']])); 186 | } 187 | } 188 | 189 | } else { 190 | 191 | if(file_exists(ROOT.DS.'inbox-log.txt')) { 192 | echo(nl2br(file_get_contents(ROOT.DS.'inbox-log.txt'))); 193 | } else { 194 | echo('no inbox activity'); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /lib/activitypub-outbox.php: -------------------------------------------------------------------------------- 1 | 'https://www.w3.org/ns/activitystreams', 13 | 'id' => $config['url'].'/outbox', 14 | 'type' => 'OrderedCollection', 15 | 'totalItems' => $posts_total, 16 | 'first' => $config['url'].'/outbox?page=1', 17 | 'last' => $config['url'].'/outbox?page='.$total_pages 18 | ]; 19 | 20 | header('Content-Type: application/ld+json'); 21 | echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 22 | else: 23 | 24 | // pagination 25 | $current_page = (isset($_GET['page']) && is_numeric($_GET['page'])) ? (int) $_GET['page'] : 1; 26 | $offset = ($current_page-1)*$posts_per_page; 27 | 28 | if($current_page < 1 || $current_page > $total_pages) { 29 | http_response_code(404); 30 | header('Content-Type: application/ld+json'); 31 | die('{}'); 32 | } 33 | 34 | $posts = db_select_posts(NOW, $posts_per_page, 'desc', $offset); 35 | 36 | $ordered_items = []; 37 | if(!empty($posts)) { 38 | foreach ($posts as $post) { 39 | 40 | $item = []; 41 | $item['id'] = $config['url'].'/'.$post['id'].'/json'; 42 | $item['type'] = 'Create'; 43 | $item['actor'] = $config['url'].'/actor'; 44 | $item['published'] = gmdate('Y-m-d\TH:i:s\Z', $post['post_timestamp']); 45 | $item['to'] = ['https://www.w3.org/ns/activitystreams#Public']; 46 | $item['cc'] = [$config['url'].'/followers']; 47 | $item['object'] = $config['url'].'/'.$post['id'].'/'; 48 | 49 | $ordered_items[] = $item; 50 | } 51 | } 52 | 53 | $output = [ 54 | '@context' => 'https://www.w3.org/ns/activitystreams', 55 | 'id' => $config['url'].'/outbox?page='.$current_page, 56 | 'type' => 'OrderedCollectionPage', 57 | 'partOf' => $config['url'].'/outbox' 58 | ]; 59 | 60 | if($current_page > 1) { 61 | $output['prev'] = $config['url'].'/outbox?page='.($current_page-1); 62 | } 63 | 64 | if($current_page < $total_pages) { 65 | $output['next'] = $config['url'].'/outbox?page='.($current_page+1); 66 | } 67 | 68 | $output['orderedItems'] = $ordered_items; 69 | 70 | header('Content-Type: application/ld+json'); 71 | echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 72 | endif; 73 | -------------------------------------------------------------------------------- /lib/activitypub-webfinger.php: -------------------------------------------------------------------------------- 1 | 'acct:'.$actor.'@'.$domain, 14 | 'links' => [ 15 | [ 16 | 'rel' => 'self', 17 | 'type' => 'application/activity+json', 18 | 'href' => $config['url'].'/actor' 19 | ] 20 | ] 21 | ]; 22 | 23 | header('Content-Type: application/jrd+json'); 24 | echo(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 25 | -------------------------------------------------------------------------------- /lib/atprotocol.php: -------------------------------------------------------------------------------- 1 | '', 6 | 'host' => '' 7 | ]; 8 | 9 | if(empty($handle)) return $return; 10 | list($return['user'], $return['host']) = explode('.', ltrim($handle, '@'), 2); 11 | 12 | return $return; 13 | } 14 | 15 | function at_datetime($timestamp=false) { 16 | // format: 2023-10-08T00:31:12.156888Z 17 | if(!$timestamp) $timestamp = microtime(true); 18 | 19 | $d = DateTime::createFromFormat('U.u', $timestamp); 20 | if(!$d) { 21 | $d = DateTime::createFromFormat('U', $timestamp); 22 | } 23 | // $d->setTimezone(new DateTimeZone('UTC')); 24 | 25 | return $d->format('Y-m-d\TH:i:s.u\Z'); 26 | } 27 | 28 | function at_get_token($handle, $password, $curl=false) { 29 | $data = at_parse_handle($handle); 30 | 31 | $ch = $curl ? $curl : curl_init(); 32 | 33 | $url = sprintf('https://%s/xrpc/com.atproto.server.createSession', $data['host']); 34 | $post_data = [ 35 | 'identifier' => ltrim($handle, '@'), 36 | 'password' => $password 37 | ]; 38 | 39 | curl_setopt($ch, CURLOPT_URL, $url); 40 | curl_setopt($ch, CURLOPT_POST, 1); 41 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data)); 42 | curl_setopt($ch, CURLOPT_HTTPHEADER, array( 43 | 'Content-Type: application/json' 44 | )); 45 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 46 | 47 | $server_output = curl_exec($ch); 48 | if(!$curl) curl_close($ch); 49 | 50 | $auth_data = json_decode($server_output, true); 51 | 52 | return $auth_data; 53 | } 54 | 55 | function at_parse_urls($text) { 56 | $spans = []; 57 | $pattern = regex_patterns('url'); 58 | 59 | preg_match_all("#$pattern#i", $text, $matches, PREG_OFFSET_CAPTURE); 60 | 61 | if(!empty($matches) && !empty($matches[0])) { 62 | for ($i=0; $i [ 77 | 'byteStart' => $uri[1], 78 | 'byteEnd' => $uri[2], 79 | ], 80 | 'features' => [ 81 | [ 82 | '$type' => "app.bsky.richtext.facet#link", 83 | 'uri' => $uri[0], # NOTE: URI ("I") not URL ("L") 84 | ] 85 | ] 86 | ]; 87 | } 88 | return $facets; 89 | } 90 | 91 | function at_new_blob($handle, $password, $token, $image, $curl=false) { 92 | $data = at_parse_handle($handle); 93 | 94 | $ch = $curl ? $curl : curl_init(); 95 | 96 | $url = sprintf('https://%s/xrpc/com.atproto.repo.uploadBlob', $data['host']); 97 | curl_setopt($ch, CURLOPT_URL, $url); 98 | curl_setopt($ch, CURLOPT_POST, 1); 99 | curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents($image)); 100 | curl_setopt($ch, CURLOPT_HTTPHEADER, array( 101 | 'Content-Type: image/jpeg', 102 | 'Authorization: Bearer '.$token 103 | )); 104 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 105 | 106 | $server_output = curl_exec($ch); 107 | 108 | if(!$curl) curl_close($ch); 109 | 110 | $response = json_decode($server_output, true); 111 | 112 | return $response['blob']; 113 | } 114 | 115 | function at_new_post($handle, $password, $text, $images=[]) { 116 | $data = at_parse_handle($handle); 117 | 118 | $ch = curl_init(); 119 | 120 | $auth_data = at_get_token($handle, $password, $ch); 121 | 122 | // IMAGES 123 | $embeds = []; 124 | $images = array_slice($images, 0, 4); // limit to max 4 images 125 | foreach ($images as $image) { 126 | $type = mime_content_type($image); 127 | 128 | // todo: support PNG! 129 | // this size limit is specified in the app.bsky.embed.images lexicon 130 | if(filesize($image) < 100000 && ($type == 'image/jpeg' || $type == 'image/jpg')) { 131 | // these images are good to go 132 | $path = $image; 133 | } else { 134 | // scale down and save to temp 135 | // hope to fit withing bluesky 1MB limit 136 | $max_dimensions = 2000; 137 | $quality = 65; 138 | 139 | list($width, $height) = getimagesize($image); 140 | $ratio = $width/$height; 141 | 142 | // do we need to scale down? 143 | $scale = false; 144 | if($width > $max_dimensions || $height > $max_dimensions) { 145 | $scale = true; 146 | if($ratio > 1) { 147 | $new_width = $max_dimensions; 148 | $new_height = floor($max_dimensions/$ratio); 149 | } else { 150 | $new_width = floor($max_dimensions*$ratio); 151 | $new_height = $max_dimensions; 152 | } 153 | } 154 | 155 | if(class_exists('Imagick')) { 156 | // use Imagick to handle image 157 | $img = new Imagick($image); 158 | $profiles = $img->getImageProfiles('icc', true); 159 | 160 | // bake in EXIF orientation 161 | $orientation = $img->getImageOrientation(); 162 | 163 | switch($orientation) { 164 | case imagick::ORIENTATION_BOTTOMRIGHT: 165 | $img->rotateimage('#000', 180); // rotate 180 degrees 166 | break; 167 | 168 | case imagick::ORIENTATION_RIGHTTOP: 169 | $img->rotateimage('#000', 90); // rotate 90 degrees CW 170 | break; 171 | 172 | case imagick::ORIENTATION_LEFTBOTTOM: 173 | $img->rotateimage('#000', -90); // rotate 90 degrees CCW 174 | break; 175 | } 176 | 177 | $img->setImageCompression(imagick::COMPRESSION_JPEG); 178 | $img->setImageCompressionQuality($quality); 179 | 180 | if($scale == true) { 181 | $img->resizeImage( 182 | $new_width, 183 | $new_height, 184 | imagick::FILTER_CATROM, 185 | 1, 186 | true 187 | ); 188 | } 189 | 190 | $img->stripImage(); 191 | 192 | // reset orientation info 193 | $img->setImageOrientation(imagick::ORIENTATION_TOPLEFT); 194 | 195 | if(!empty($profiles)) { 196 | $img->profileImage('icc', $profiles['icc']); 197 | } 198 | 199 | $tmp = tmpfile(); 200 | $path = stream_get_meta_data($tmp)['uri']; 201 | 202 | $img->writeImage('jpg:'.$path); 203 | // $img->writeImage('jpg:'.ROOT.DS.'test-'.microtime(true).'.jpg'); 204 | $img->clear(); 205 | } else { 206 | // use GD to handle image 207 | $res = imagecreatefromstring(file_get_contents($image)); 208 | 209 | $tmp = tmpfile(); 210 | $path = stream_get_meta_data($tmp)['uri']; 211 | 212 | if($scale == true) { 213 | $resized = imagecreatetruecolor($new_width, $new_height); 214 | 215 | // todo: do we need to fix orientation here, too? 216 | 217 | imagecopyresampled($resized, $res, 0, 0, 0, 0, $new_width, $new_height, $width, $height); 218 | imagejpeg($resized, $tmp, $quality); 219 | // imagejpeg($resized, ROOT.DS.'test-'.microtime(true).'.jpg', $quality); 220 | 221 | imagedestroy($resized); 222 | imagedestroy($res); 223 | } else { 224 | imagejpeg($res, $tmp, $quality); 225 | imagedestroy($res); 226 | } 227 | } 228 | } 229 | 230 | $blob = at_new_blob($handle, $password, $auth_data['accessJwt'], $path, $ch); 231 | 232 | $embeds[] = [ 233 | "alt" => '', 234 | "image" => $blob 235 | ]; 236 | } 237 | 238 | // URLs 239 | $facets = at_urls_to_facets($text); 240 | 241 | $new_post = [ 242 | '$type' => 'app.bsky.feed.post', 243 | 'text' => $text, 244 | 'createdAt' => at_datetime(microtime(true)), 245 | // 'langs' => [ 'en' ], 246 | ]; 247 | if(!empty($embeds)) $new_post['embed'] = [ 248 | '$type' => "app.bsky.embed.images", 249 | 'images' => $embeds 250 | ]; 251 | if(!empty($facets)) $new_post['facets'] = $facets; 252 | 253 | // var_dump(json_encode($new_post)); 254 | 255 | $post_data = [ 256 | "repo" => $auth_data['did'], 257 | "collection" => 'app.bsky.feed.post', 258 | "record" => $new_post 259 | ]; 260 | 261 | $url = sprintf('https://%s/xrpc/com.atproto.repo.createRecord', $data['host']); 262 | 263 | curl_setopt($ch, CURLOPT_URL, $url); 264 | curl_setopt($ch, CURLOPT_POST, 1); 265 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data)); 266 | curl_setopt($ch, CURLOPT_HTTPHEADER, array( 267 | 'Content-Type: application/json', 268 | 'Authorization: Bearer '.$auth_data['accessJwt'] 269 | )); 270 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 271 | 272 | $server_output = curl_exec($ch); 273 | curl_close($ch); 274 | 275 | $status = json_decode($server_output, true); 276 | //var_dump($status); 277 | 278 | return $status; 279 | } 280 | 281 | function at_post_status($post_id) { 282 | global $config; 283 | 284 | // $post_id = 40; // testing only! 285 | $post = db_select_post($post_id); 286 | 287 | $handle = $config['at_handle']; 288 | $app_password = $config['at_password']; 289 | 290 | // get image attachments 291 | $files = db_get_attached_files($post['id']); 292 | $images = []; 293 | if(!empty($files)) { 294 | $files = array_values($files)[0]; 295 | $images = array_map(function ($file) { 296 | if(strpos($file['file_mime_type'], 'image') !== 0) return false; // skip non-image files 297 | return realpath(ROOT .'/'. get_file_path($file)); 298 | }, $files); 299 | } 300 | 301 | if(1 == 1) { 302 | // add a permalink to bluesky posts 303 | $post['post_content'] = $post['post_content'] . "\n\n" . $config['url'] . '/' . $post['id']; 304 | } 305 | 306 | $status = at_new_post($handle, $app_password, $post['post_content'], $images); 307 | 308 | return $status; // todo: save this as post meta? 309 | } 310 | -------------------------------------------------------------------------------- /lib/bar.php: -------------------------------------------------------------------------------- 1 | query($sql); 71 | $posts = $statement->fetchAll(PDO::FETCH_ASSOC); 72 | 73 | $json = rebuild_json_feed($posts, true, true); // return as string, relative paths 74 | $html = bar_generate_hfeed($posts); 75 | 76 | $zip = new ZipArchive; 77 | $archive_filename = 'mb-'.time().'-'.bin2hex(random_bytes(4)).'.zip'; // make unguessable 78 | 79 | if ($zip->open($files_dir.DS.'bar'.DS.$archive_filename, ZipArchive::CREATE) === TRUE) { 80 | $zip->addFromString('index.html', $html); 81 | $zip->addFromString('feed.json', $json); 82 | 83 | $files = new RecursiveIteratorIterator( 84 | new RecursiveDirectoryIterator($files_dir, RecursiveDirectoryIterator::SKIP_DOTS), 85 | RecursiveIteratorIterator::LEAVES_ONLY 86 | ); 87 | 88 | foreach ($files as $file) { 89 | if (!$file->isDir()) { 90 | $filePath = $file->getRealPath(); 91 | $relativePath = substr($filePath, strlen($files_dir) + 1); 92 | list($subdir) = explode(DS, $relativePath); 93 | 94 | if(is_numeric($subdir)) { // only add year subdirs 95 | $zip->addFile($filePath, 'files/'.$relativePath); 96 | } 97 | } 98 | } 99 | 100 | $zip->close(); 101 | } else return false; 102 | 103 | return true; 104 | } 105 | 106 | function bar_generate_hfeed($posts=[]) { 107 | global $settings; 108 | 109 | $doc = new DOMDocument; 110 | $doc->preserveWhiteSpace = false; 111 | $doc->formatOutput = true; 112 | $html = $doc->appendChild($doc->createElement('html')); 113 | $head = $html->appendChild($doc->createElement('head')); 114 | 115 | $meta = array( 116 | array('charset' => 'utf-8'), 117 | ); 118 | 119 | foreach ($meta as $attributes) { 120 | $node = $head->appendChild($doc->createElement('meta')); 121 | foreach ($attributes as $key => $value) { 122 | $node->setAttribute($key, $value); 123 | } 124 | } 125 | 126 | $title = $head->appendChild($doc->createElement('title')); 127 | $title->nodeValue = $settings['site_title']; 128 | 129 | $body = $html->appendChild($doc->createElement('body')); 130 | $hfeed = $body->appendChild($doc->createElement('section')); 131 | $hfeed->setAttribute('class', 'h-feed'); 132 | 133 | $h1 = $hfeed->appendChild($doc->createElement('h1')); 134 | $h1->setAttribute('class', 'p-name site-title'); 135 | 136 | $claim = $hfeed->appendChild($doc->createElement('p')); 137 | $claim->setAttribute('class', 'p-summary site-description'); 138 | $claim->nodeValue = $settings['site_claim']; 139 | 140 | $node = $h1->appendChild($doc->createElement('a')); 141 | $node->setAttribute('class', 'u-url'); 142 | $node->setAttribute('href', $settings['url']); 143 | $node->nodeValue = $settings['site_title']; 144 | 145 | foreach ($posts as $post) { 146 | $node = $hfeed->appendChild($doc->createElement('article')); 147 | $node->setAttribute('class', 'h-entry hentry'); 148 | $node->setAttribute('data-id', $post['id']); 149 | 150 | $permalink = $node->appendChild($doc->createElement('a')); 151 | $permalink->setAttribute('class', 'u-url u-uid'); 152 | $permalink->setAttribute('href', $settings['url'].'/'.$post['id']); 153 | 154 | $time = $permalink->appendChild($doc->createElement('time')); 155 | $time->setAttribute('class', 'dt-published published'); 156 | $time->setAttribute('datetime', date('Y-m-d H:i:s', $post['post_timestamp'])); 157 | $time->nodeValue = date('Y-m-d H:i:s', $post['post_timestamp']); 158 | 159 | if(!empty($post['post_edited'])) { 160 | $edittime = $permalink->appendChild($doc->createElement('time')); 161 | $edittime->setAttribute('class', 'dt-updated'); 162 | $edittime->setAttribute('datetime', date('Y-m-d H:i:s', $post['post_edited'])); 163 | $edittime->nodeValue = date('Y-m-d H:i:s', $post['post_edited']); 164 | } 165 | 166 | $author = $node->appendChild($doc->createElement('p')); 167 | $author->setAttribute('class', 'p-author author h-card vcard'); 168 | $author->nodeValue = $settings['microblog_account']; 169 | 170 | $content = $node->appendChild($doc->createElement('div')); 171 | $content->setAttribute('class', 'e-content'); 172 | 173 | $p = $content->appendChild($doc->createElement('p')); 174 | $fragment = $p->ownerDocument->createDocumentFragment(); 175 | $fragment->appendXML(autolink($post['post_content'])); // fragile? 176 | $p->appendChild($fragment); 177 | // $p->nodeValue = autolink($post['post_content']); 178 | 179 | if(!empty($post['post_attachments'])) { 180 | // todo: distinguish between images and other file types 181 | // tricky, because has to check in DB 182 | $images = explode(',', $post['post_attachments']); 183 | foreach ($images as $img) { 184 | $image = $content->appendChild($doc->createElement('img')); 185 | $image->setAttribute('class', 'u-photo'); 186 | $image->setAttribute('src', './files/'.$img); 187 | } 188 | } 189 | } 190 | 191 | $doc->formatOutput = true; 192 | $markup = $doc->saveXML(); // saveHTML has fewer options 193 | 194 | // spaces to tabs (?) and cleanup 195 | $markup = preg_replace_callback('/^( +)saveXML()); 198 | $markup = str_replace('', '', $markup); // LIBXML_NOXMLDECL is PHP 8.3+ 199 | 200 | file_put_contents(ROOT.DS.'feed'.DS.'feed.html', $markup); 201 | // echo($markup); 202 | 203 | return $markup; 204 | } 205 | -------------------------------------------------------------------------------- /lib/database.php: -------------------------------------------------------------------------------- 1 | setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 9 | $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); 10 | 11 | $db_version = $db->query("PRAGMA user_version")->fetch(PDO::FETCH_ASSOC)['user_version']; 12 | } catch(PDOException $e) { 13 | print 'Exception : '.$e->getMessage(); 14 | die('cannot connect to or open the database'); 15 | } 16 | 17 | // first time setup 18 | if($db_version == 0) { 19 | try { 20 | $db->exec("PRAGMA `user_version` = 1; 21 | CREATE TABLE IF NOT EXISTS `posts` ( 22 | `id` INTEGER PRIMARY KEY NOT NULL, 23 | `post_content` TEXT, 24 | `post_timestamp` INTEGER 25 | );"); 26 | $db_version = 1; 27 | } catch(PDOException $e) { 28 | print 'Exception : '.$e->getMessage(); 29 | die('cannot set up initial database table!'); 30 | } 31 | } 32 | 33 | // upgrade database to v2 34 | if($db_version == 1) { 35 | try { 36 | $db->exec("PRAGMA `user_version` = 2; 37 | ALTER TABLE `posts` ADD `post_thread` INTEGER; 38 | ALTER TABLE `posts` ADD `post_edited` INTEGER; 39 | ALTER TABLE `posts` ADD `post_deleted` INTEGER; 40 | "); 41 | $db_version = 2; 42 | } catch(PDOException $e) { 43 | print 'Exception : '.$e->getMessage(); 44 | die('cannot upgrade database table to v2!'); 45 | } 46 | } 47 | 48 | // upgrade database to v3 49 | if($db_version == 2) { 50 | try { 51 | $db->exec("PRAGMA `user_version` = 3; 52 | ALTER TABLE `posts` ADD `post_guid` TEXT; 53 | "); 54 | $db_version = 3; 55 | } catch(PDOException $e) { 56 | print 'Exception : '.$e->getMessage(); 57 | die('cannot upgrade database table to v3!'); 58 | } 59 | } 60 | 61 | // upgrade database to v4 62 | if($db_version == 3) { 63 | try { 64 | $db->exec("PRAGMA `user_version` = 4; 65 | CREATE TABLE `files` ( 66 | `id` INTEGER PRIMARY KEY NOT NULL, 67 | `file_filename` TEXT NOT NULL, 68 | `file_extension` TEXT, 69 | `file_original` TEXT NOT NULL, 70 | `file_mime_type` TEXT, 71 | `file_size` INTEGER, 72 | `file_hash` TEXT UNIQUE, 73 | `file_hash_algo` TEXT, 74 | `file_meta` TEXT DEFAULT '{}', 75 | `file_dir` TEXT, 76 | `file_subdir` TEXT, 77 | `file_timestamp` INTEGER, 78 | `file_deleted` INTEGER 79 | ); 80 | CREATE TABLE `file_to_post` ( 81 | `file_id` INTEGER NOT NULL, 82 | `post_id` INTEGER NOT NULL, 83 | `deleted` INTEGER, 84 | UNIQUE(`file_id`, `post_id`) ON CONFLICT IGNORE 85 | ); 86 | CREATE INDEX `posts_timestamp` ON posts (`post_timestamp`); 87 | CREATE INDEX `files_original` ON files (`file_original`); 88 | CREATE INDEX `link_deleted` ON file_to_post (`deleted`); 89 | CREATE UNIQUE INDEX `files_hashes` ON files (`file_hash`); 90 | "); 91 | $db_version = 4; 92 | } catch(PDOException $e) { 93 | print 'Exception : '.$e->getMessage(); 94 | die('cannot upgrade database table to v4!'); 95 | } 96 | } 97 | 98 | // v5, update for activitypub 99 | if($db_version == 4) { 100 | try { 101 | $db->exec("PRAGMA `user_version` = 5; 102 | CREATE TABLE IF NOT EXISTS `followers` ( 103 | `id` INTEGER PRIMARY KEY NOT NULL, 104 | `follower_name` TEXT NOT NULL, 105 | `follower_host` TEXT NOT NULL, 106 | `follower_actor` TEXT, 107 | `follower_inbox` TEXT, 108 | `follower_shared_inbox` TEXT, 109 | `follower_added` INTEGER 110 | ); 111 | CREATE UNIQUE INDEX `followers_users` ON followers (`follower_name`, `follower_host`); 112 | CREATE INDEX `followers_shared_inboxes` ON followers (`follower_shared_inbox`); 113 | "); 114 | $db_version = 5; 115 | } catch(PDOException $e) { 116 | print 'Exception : '.$e->getMessage(); 117 | die('cannot upgrade database table to v5!'); 118 | } 119 | } 120 | 121 | // v6, update for activitypub likes and announces 122 | if($db_version == 5) { 123 | try { 124 | $db->exec("PRAGMA `user_version` = 6; 125 | CREATE TABLE IF NOT EXISTS `activities` ( 126 | `id` INTEGER PRIMARY KEY NOT NULL, 127 | `activity_actor_name` TEXT NOT NULL, 128 | `activity_actor_host` TEXT NOT NULL, 129 | `activity_type` TEXT NOT NULL, 130 | `activity_object_id` INTEGER NOT NULL, 131 | `activity_updated` INTEGER 132 | ); 133 | CREATE INDEX `activities_objects` ON activities (`activity_object_id`); 134 | CREATE UNIQUE INDEX `activities_unique` ON activities (`activity_actor_name`, `activity_actor_host`, `activity_type`, `activity_object_id`); 135 | "); 136 | $db_version = 6; 137 | } catch(PDOException $e) { 138 | print 'Exception : '.$e->getMessage(); 139 | die('cannot upgrade database table to v6!'); 140 | } 141 | } 142 | 143 | // v7, update for activitypub key storage 144 | if($db_version == 6) { 145 | try { 146 | $db->exec("PRAGMA `user_version` = 7; 147 | CREATE TABLE IF NOT EXISTS `keys` ( 148 | `id` INTEGER PRIMARY KEY NOT NULL, 149 | `key_private` TEXT NOT NULL, 150 | `key_public` TEXT NOT NULL, 151 | `key_algo` TEXT DEFAULT 'sha512', 152 | `key_bits` INTEGER DEFAULT 4096, 153 | `key_type` TEXT DEFAULT 'rsa', 154 | `key_created` INTEGER 155 | ); 156 | "); 157 | $db_version = 7; 158 | } catch(PDOException $e) { 159 | print 'Exception : '.$e->getMessage(); 160 | die('cannot upgrade database table to v7!'); 161 | } 162 | } 163 | 164 | // v8, update for config/settings key/value storage 165 | if($db_version == 7) { 166 | try { 167 | $db_version += 1; 168 | $install_signature = bin2hex(random_bytes(16)); 169 | $db->exec("PRAGMA `user_version` = ".$db_version."; 170 | CREATE TABLE IF NOT EXISTS `settings` ( 171 | `id` INTEGER PRIMARY KEY NOT NULL, 172 | `settings_key` TEXT NOT NULL UNIQUE, 173 | `settings_value` TEXT, 174 | `settings_value_previous` TEXT, 175 | `settings_updated` INTEGER 176 | ); 177 | CREATE UNIQUE INDEX `settings_keys` ON settings (`settings_key`); 178 | INSERT INTO `settings` (settings_key, settings_value, settings_updated) VALUES ('installation_signature', '".$install_signature."', ".NOW."); 179 | INSERT INTO `settings` (settings_key, settings_value, settings_updated) VALUES ('do_setup', '1', ".NOW."); 180 | INSERT INTO `settings` (settings_key, settings_value, settings_updated) VALUES ('passkey', '', ".NOW."); 181 | "); 182 | } catch(PDOException $e) { 183 | print 'Exception : '.$e->getMessage(); 184 | die('cannot upgrade database table to v'.$db_version.'!'); 185 | } 186 | } 187 | 188 | // debug: get a list of post table columns 189 | // var_dump($db->query("PRAGMA table_info(`posts`)")->fetchAll(PDO::FETCH_COLUMN, 1)); 190 | -------------------------------------------------------------------------------- /lib/passkeys.php: -------------------------------------------------------------------------------- 1 | -2, 25 | 'errors' => ['Method not supported'] 26 | ])); 27 | exit(); 28 | } 29 | 30 | // Challenge 31 | if ($method == 'GET') { 32 | $_SESSION['challenge'] = random_bytes(32); 33 | } 34 | $challenge = ($_SESSION['challenge'] ?? ''); 35 | 36 | // If submitted 37 | $errors = []; 38 | if ($action == 'store') { 39 | if(!$config['logged_in']) { 40 | header('HTTP/1.0 401 Unauthorized'); 41 | echo(json_encode(['errors' => ['Unauthorized access!']])); 42 | exit(); 43 | } 44 | 45 | $data = file_get_contents('php://input'); 46 | if(empty($data)) exit('{}'); 47 | 48 | // Parse 49 | $webauthn_data = json_decode($data, true); 50 | 51 | // Client data 52 | $client_data_json = base64_decode($webauthn_data['response']['clientDataJSON'] ?? ''); 53 | $client_data = json_decode($client_data_json, true); 54 | 55 | // Auth data 56 | $auth_data = base64_decode($webauthn_data['response']['authenticatorData']); 57 | 58 | $auth_data_relying_party_id = substr($auth_data, 0, 32); // rpIdHash 59 | $auth_data_flags = substr($auth_data, 32, 1); 60 | $auth_data_sign_count = substr($auth_data, 33, 4); 61 | $auth_data_sign_count = intval(implode('', unpack('N*', $auth_data_sign_count))); // 32-bit unsigned big-endian integer 62 | 63 | // Checks basic 64 | if (($webauthn_data['type'] ?? '') !== 'public-key') { 65 | $errors[] = 'Returned type is not a "public-key".'; 66 | } 67 | 68 | if (($client_data['type'] ?? '') !== 'webauthn.create') { 69 | $errors[] = 'Returned type is not "webauthn.create".'; 70 | } 71 | 72 | if (($client_data['origin'] ?? '') !== $origin) { 73 | $errors[] = 'Returned origin is not "' . $origin . '".'; 74 | } 75 | 76 | if (strlen($auth_data_relying_party_id) != 32 || !hash_equals(hash('sha256', $host), bin2hex($auth_data_relying_party_id))) { 77 | $errors[] = 'The Relying Party ID hash is not the same.'; 78 | } 79 | 80 | // Check challenge 81 | $response_challenge = ($client_data['challenge'] ?? ''); 82 | $response_challenge = base64_decode(strtr($response_challenge, '-_', '+/')); 83 | 84 | if (!$challenge) { 85 | $errors[] = 'The challenge has not been stored in the session.'; 86 | } else if (substr_compare($response_challenge, $challenge, 0) !== 0) { 87 | $errors[] = 'The challenge has changed.'; 88 | } 89 | 90 | // Only use $challenge check for attestation 91 | 92 | // Get public key 93 | $key_der = ($webauthn_data['response']['publicKey'] ?? NULL); 94 | if (!$key_der) { 95 | $errors[] = 'No public key found.'; 96 | } 97 | 98 | if (($webauthn_data['response']['publicKeyAlg'] ?? NULL) !== $algorithm) { 99 | $errors[] = 'Different algorithm used.'; 100 | } 101 | 102 | // Store 103 | if (count($errors) == 0) { 104 | 105 | //file_put_contents(ROOT.DS.'pk-log.txt', json_encode([$key_der, $webauthn_data])); 106 | 107 | try { 108 | $statement = $db->prepare('INSERT OR REPLACE INTO settings (settings_key, settings_value, settings_updated) VALUES (:settings_key, :settings_value, :settings_updated)'); 109 | $statement->bindValue(':settings_key', 'passkey', PDO::PARAM_STR); 110 | $statement->bindValue(':settings_value', json_encode($webauthn_data), PDO::PARAM_STR); 111 | $statement->bindValue(':settings_updated', time(), PDO::PARAM_INT); 112 | 113 | $statement->execute(); 114 | 115 | $id = $db->lastInsertId() || -1; 116 | 117 | } catch(PDOException $e) { 118 | $errors[] = 'Exception : '.$e->getMessage(); 119 | $id = -1; 120 | } 121 | } 122 | 123 | // Show errors 124 | echo(json_encode([ 125 | 'result' => $id, 126 | 'errors' => $errors 127 | ])); 128 | exit(); 129 | } 130 | 131 | // Request 132 | if($action == 'create') { 133 | 134 | $request = [ 135 | 'publicKey' => [ 136 | 'rp' => [ 137 | 'name' => 'Microblog', 138 | 'id' => $host, 139 | 'icon' => $config['url'].'/favicon-large.png' // is this real? 140 | ], 141 | 'user' => [ 142 | 'id' => 1, 143 | 'name' => 'admin', 144 | 'displayName' => 'admin', 145 | ], 146 | 'challenge' => base64_encode($challenge), 147 | 'pubKeyCredParams' => [ 148 | [ 149 | 'type' => "public-key", 150 | 'alg' => $algorithm, 151 | ] 152 | ], 153 | 'authenticatorSelection' => [ 154 | 'authenticatorAttachment' => 'platform' // platform, cross-platform 155 | ], 156 | 'timeout' => 60000, // In milliseconds 157 | 'attestation' => 'none', // "none", "direct", "indirect" 158 | 'excludeCredentials' => [ // Avoid creating new public key credentials (e.g. existing user who has already setup WebAuthn). This is filled in L170+ 159 | /* 160 | [ 161 | 'type' => "public-key", 162 | 'id' => $passkey['id'], 163 | ], 164 | */ 165 | ], 166 | 'userVerification' => 'discouraged' 167 | ], 168 | ]; 169 | 170 | // prevent duplicate setup 171 | if(!empty($config['passkey'])) { 172 | $passkey = json_decode($config['passkey'], true); 173 | 174 | $request['publicKey']['excludeCredentials'][] = [ 175 | 'type' => "public-key", 176 | 'id' => $passkey['id'] // Base64-encoded value (?) 177 | ]; 178 | } 179 | 180 | echo(json_encode($request)); 181 | exit(); 182 | } 183 | 184 | if($action == 'login') { 185 | 186 | $passkey_json = db_get_setting('passkey'); 187 | $passkey = null; 188 | if($passkey_json) { 189 | $passkey = json_decode($passkey_json, true); 190 | } 191 | 192 | $create_auth = $passkey_json; // Only for debugging. 193 | 194 | $user_key_id = ($passkey['id'] ?? ''); 195 | $user_key_value = ($passkey['response']['publicKey'] ?? ''); 196 | 197 | if (!$user_key_id) { 198 | exit('Missing user key id.'); 199 | } 200 | 201 | if (!$user_key_value) { 202 | exit('Missing user key value.'); 203 | } 204 | 205 | $request = [ 206 | 'publicKey' => [ 207 | 'rpId' => $host, 208 | 'challenge' => base64_encode($challenge), 209 | 'timeout' => 60000, // In milliseconds 210 | 'allowCredentials' => [ 211 | [ 212 | 'type' => 'public-key', 213 | 'id' => $user_key_id, 214 | ] 215 | ], 216 | 'userVerification' => 'discouraged' 217 | ] 218 | ]; 219 | 220 | echo(json_encode($request)); 221 | exit(); 222 | } 223 | 224 | if($action == 'revoke') { 225 | if(!$config['logged_in']) { 226 | header('HTTP/1.0 401 Unauthorized'); 227 | echo(json_encode(['errors' => ['Unauthorized access!']])); 228 | exit(); 229 | } 230 | 231 | $result = 0; 232 | $errors = []; 233 | try { 234 | // $statement = $db->prepare('DELETE FROM settings WHERE settings_key = "passkey"'); 235 | $statement = $db->prepare('UPDATE settings SET settings_value = "" WHERE settings_key = "passkey"'); 236 | $statement->execute(); 237 | 238 | $result = $statement->rowCount(); 239 | } catch(PDOException $e) { 240 | $result = -1; 241 | $errors[] = 'Exception : '.$e->getMessage(); 242 | } 243 | 244 | echo(json_encode([ 245 | 'result' => $result, 246 | 'errors' => $errors 247 | ])); 248 | exit(); 249 | } 250 | 251 | if($action == 'verify') { 252 | $data = file_get_contents('php://input'); 253 | $errors = []; 254 | 255 | $passkey_json = db_get_setting('passkey'); 256 | $passkey = null; 257 | if($passkey_json) { 258 | $passkey = json_decode($passkey_json, true); 259 | } 260 | 261 | $webauthn_data = json_decode($data, true); 262 | 263 | $create_auth = $passkey_json; // Only for debugging. 264 | 265 | $user_key_id = ($passkey['id'] ?? ''); 266 | $user_key_value = ($passkey['response']['publicKey'] ?? ''); 267 | 268 | if (!$user_key_id) { 269 | exit('Missing user key id.'); 270 | } 271 | 272 | if (!$user_key_value) { 273 | exit('Missing user key value.'); 274 | } 275 | 276 | $client_data_json = base64_decode($webauthn_data['response']['clientDataJSON'] ?? ''); 277 | $client_data = json_decode($client_data_json, true); 278 | 279 | $auth_data = base64_decode($webauthn_data['response']['authenticatorData']); 280 | 281 | $auth_data_relying_party_id = substr($auth_data, 0, 32); // rpIdHash 282 | $auth_data_flags = substr($auth_data, 32, 1); 283 | $auth_data_sign_count = substr($auth_data, 33, 4); 284 | $auth_data_sign_count = intval(implode('', unpack('N*', $auth_data_sign_count))); // 32-bit unsigned big-endian integer 285 | 286 | // Checks basic 287 | if (($webauthn_data['id'] ?? '') !== $user_key_id) { 288 | $errors[] = 'Returned type is not for the same id.'; 289 | } 290 | 291 | if (($webauthn_data['type'] ?? '') !== 'public-key') { 292 | $errors[] = 'Returned type is not a "public-key".'; 293 | } 294 | 295 | if (($client_data['type'] ?? '') !== 'webauthn.get') { 296 | $errors[] = 'Returned type is not "webauthn.get".'; 297 | } 298 | 299 | if (($client_data['origin'] ?? '') !== $origin) { 300 | $errors[] = 'Returned origin is not "' . $origin . '".'; 301 | } 302 | 303 | if (strlen($auth_data_relying_party_id) != 32 || !hash_equals(hash('sha256', $host), bin2hex($auth_data_relying_party_id))) { 304 | $errors[] = 'The Relying Party ID hash is not the same.'; 305 | } 306 | 307 | // Check challenge 308 | $response_challenge = ($client_data['challenge'] ?? ''); 309 | $response_challenge = base64_decode(strtr($response_challenge, '-_', '+/')); 310 | 311 | if (!$challenge) { 312 | $errors[] = 'The challenge has not been stored in the session.'; 313 | } else if (substr_compare($response_challenge, $challenge, 0) !== 0) { 314 | $errors[] = 'The challenge has changed.'; 315 | } 316 | 317 | // Check signature 318 | $signature = ($webauthn_data['response']['signature'] ?? ''); 319 | if ($signature) { 320 | $signature = base64_decode($signature); 321 | } 322 | 323 | if (!$signature) { 324 | $errors[] = 'No signature returned.'; 325 | } 326 | 327 | // Key 328 | $key_info = NULL; 329 | if (count($errors) == 0) { 330 | 331 | $user_key_pem = '-----BEGIN PUBLIC KEY-----' . "\n"; 332 | $user_key_pem .= wordwrap($user_key_value, 64, "\n", true) . "\n"; 333 | $user_key_pem .= '-----END PUBLIC KEY-----'; 334 | 335 | $key_ref = openssl_pkey_get_public($user_key_pem); 336 | 337 | if ($key_ref === false) { 338 | $errors[] = 'Public key invalid.'; 339 | } else { 340 | $key_info = openssl_pkey_get_details($key_ref); 341 | 342 | if ($key_info['type'] == OPENSSL_KEYTYPE_EC) { 343 | if ($key_info['ec']['curve_oid'] != '1.2.840.10045.3.1.7') { 344 | $errors[] = 'Invalid public key curve identifier'; 345 | } 346 | } else { 347 | $errors[] = 'Unknown public key type (' . $key_info['type'] . ')'; 348 | } 349 | } 350 | } 351 | 352 | // Check 353 | $result = 0; 354 | if (count($errors) == 0) { 355 | 356 | $verify_data = ''; 357 | $verify_data .= $auth_data; 358 | $verify_data .= hash('sha256', $client_data_json, true); // Contains the $challenge 359 | 360 | if (openssl_verify($verify_data, $signature, $key_ref, OPENSSL_ALGO_SHA256) === 1) { 361 | $result = 1; 362 | 363 | // set the login cookie 364 | $config['logged_in'] = check_login(true); 365 | } else { 366 | $errors[] = 'Invalid signature.'; 367 | $result = -1; 368 | } 369 | } 370 | 371 | echo(json_encode([ 372 | 'result' => $result, 373 | 'errors' => $errors 374 | ])); 375 | exit(); 376 | } 377 | -------------------------------------------------------------------------------- /lib/password-dict.txt: -------------------------------------------------------------------------------- 1 | about,after,again,air,all,along,also,an,and,another,any,are,around,as,at,away,back,be,because,been,before,below,between,both,but,by,came,can,come,could,day,did,different,do,does,down,each,end,even,every,few,find,first,for,found,from,get,give,go,good,great,had,has,have,he,help,her,here,him,his,home,house,how,i,if,in,into,is,it,its,just,know,large,last,left,like,line,little,long,look,made,make,man,many,may,me,men,might,more,most,mr.,must,my,name,never,new,next,no,not,now,number,of,off,old,on,one,only,or,other,our,out,over,own,part,people,place,put,read,right,said,same,saw,say,see,she,should,show,small,so,some,something,sound,still,such,take,tell,than,that,the,them,then,there,these,they,thing,think,this,those,thought,three,through,time,to,together,too,two,under,up,us,use,very,want,water,way,we,well,went,were,what,when,where,which,while,who,why,will,with,word,work,world,would,write,year,you,your,was,able,above,across,add,against,ago,almost,among,animal,answer,became,become,began,behind,being,better,black,best,body,book,boy,brought,call,cannot,car,certain,change,children,city,close,cold,country,course,cut,dog,done,door,draw,during,early,earth,eat,enough,ever,example,eye,face,family,far,father,feel,feet,fire,fish,five,food,form,four,front,gave,given,got,green,ground,group,grow,half,hand,hard,heard,high,himself,however,idea,important,inside,john,keep,kind,knew,known,land,later,learn,let,letter,life,light,live,living,making,mean,means,money,morning,mother,move,mrs.,near,night,nothing,once,open,order,page,paper,parts,perhaps,picture,play,point,ready,red,remember,rest,room,run,school,sea,second,seen,sentence,several,short,shown,since,six,slide,sometime,soon,space,states,story,sun,sure,table,though,today,told,took,top,toward,tree,try,turn,united,until,upon,using,usually,white,whole,wind,without,yes,yet,young,already,although,am,america,anything,area,ball,beautiful,beginning,Bill,birds,blue,boat,bottom,box,bring,build,building,built,care,carefully,carried,carry,center,check,class,coming,common,complete,dark,deep,distance,doing,dry,easy,either,else,everyone,everything,fact,fall,fast,felt,field,finally,fine,floor,follow,foot,friend,full,game,getting,girl,glass,goes,gold,gone,happened,having,heart,heavy,held,hold,horse,hot,hour,hundred,ice,indian,instead,itself,job,kept,language,lay,least,leave,list,longer,low,main,map,matter,mind,miss,moon,mountain,moving,music,needed,notice,outside,past,pattern,person,piece,plant,poor,possible,power,probably,problem,question,quickly,quite,rain,ran,real,river,road,rock,round,sat,scientist,shall,ship,simple,size,sky,slowly,snow,someone,special,stand,start,state,stay,stood,stop,stopped,strong,suddenly,summer,surface,system,taken,talk,tall,ten,themselves,third,tiny,town,tried,voice,walk,warm,watch,weather,whether,wide,wild,winter,within,writing,written,age,ask,baby,base,beside,bright,business,buy,case,catch,caught,child,choose,circle,clear,color,copy,correct,difference,direction,dried,easily,edge,egg,eight,energy,england,especially,europe,exactly,except,explain,famous,farm,fell,figure,flat,fly,forest,free,french,fun,george,government,grass,grew,hair,happy,heat,history,human,inch,information,iron,jim,joe,king,larger,late,leg,length,listen,lost,lot,lower,machine,mark,maybe,measure,meet,middle,milk,minute,modern,moment,month,mouth,natural,nearly,necessary,new,north,object,ocean,oil,pay,per,plan,plane,present,product,rather,reach,reason,record,running,seems,sent,seven,shape,sides,single,skin,sleep,smaller,soft,soil,south,speak,speed,spring,square,star,step,store,straight,strange,street,subject,suppose,teacher,thousand,thus,tom,travel,trip,trouble,unit,village,wall,war,week,whose,window,wish,women,wood,wrote,yellow,yourself,action,addition,afraid,afternoon,ahead,amount,ancient,anyone,arm,bad,bear,beyond,bit,blood,board,Bob,born,break,British,broken,brother,brown,busy,capital,cat,cattle,cause,century,chance,clean,clothes,coast,control,cool,corn,corner,cover,cross,Dan,dead,deal,death,decide,difficult,drive,engine,evening,farmer,faster,fight,fill,finger,force,forward,france,fresh,garden,general,glad,greater,greatest,guess,happen,henry,higher,hit,hole,hope,huge,interest,island,jack,lady,largest,lead,led,level,love,mary,material,meant,meat,method,missing,needs,nor,nose,note,opposite,pair,party,pass,period,please,position,pound,practice,pretty,produce,pull,quiet,race,radio,region,result,return,rich,ride,ring,rule,sand,science,section,seed,send,sense,sets,sharp,sight,sign,silver,similar,sit,son,song,spent,spread,stick,stone,tail,team,teeth,temperature,test,therefore,thick,thin,train,various,wait,washington,wave,weight,west,wife,wrong,according,act,actually,africa,alike,apart,ate,attention,bank,basic,beat,blow,bone,bread,careful,chair,chief,Christmas,church,cloth,cloud,column,compare,contain,continued,cost,cotton,count,dance,describe,desert,dinner,doctor,dollar,drop,dropped,ear,east,electric,element,enjoy,equal,exercise,experiment,familiar,farther,fear,forth,gas,giving,gray,grown,hardly,hat,hill,hurt,imagine,include,indeed,johnny,joined,key,kitchen,knowledge,law,lie,major,met,metal,movement,nation,nature,nine,none,office,older,onto,original,paragraph,parent,particular,path,paul,peter,pick,president,pressure,process,public,quick,report,rope,rose,row,safe,salt,sam,scale,sell,separate,sheep,shoe,shore,simply,sing,sister,sitting,sold,soldier,solve,speech,spend,steel,string,student,studied,sugar,television,term,throughout,tired,total,touch,trade,truck,twice,type,uncle,unless,useful,value,verb,visit,wear,what,wheel,william,wing,wire,won,wonder,worker,yard,alive,angry,army,average,bag,band,Billy,branch,breakfast,breath,broke,bus,cabin,California,camp,captain,cell,cent,certainly,changing,closer,coal,coat,community,company,completely,compound,condition,consider,correctly,crop,crowd,current,danger,dear,degree,develop,die,directly,discover,divide,double,Dr.,dress,drink,drove,dust,easier,effect,electricity,empty,entire,everybody,exciting,expect,experience,express,fair,feed,final,finish,flew,fruit,further,future,greek,guide,gun,herself,hungry,instrument,jane,join,jump,laid,liquid,loud,market,member,mexico,mike,mine,motion,myself,neck,news,nice,noise,noun,oxygen,paid,phrase,plain,poem,population,proper,proud,provide,purpose,putting,quietly,raise,range,rate,regular,related,replied,represent,rise,scientific,season,seat,share,shot,shoulder,slow,smile,solid,solution,sort,southern,stage,statement,station,steam,stream,strength,supply,surprise,symbol,till,tomorrow,tube,twelve,twenty,usual,valley,variety,vowel,wet,wooden,worth,airplane,angle,ann,apple,art,atlantic,atmosphere,bar,barn,baseball,beauty,believed,bell,belong,beneath,bigger,bottle,bowl,broad,chapter,chart,Chinese,clearly,climate,clock,closely,clothing,coffee,cow,cry,Dad,dangerous,deer,desk,detail,development,drew,driver,event,everywhere,fat,favorite,fence,fifty,flight,flow,flower,forget,fourth,friendly,generally,german,germany,giant,golden,grain,handle,height,hung,hurry,immediately,industry,instance,italy,james,knife,lake,latin,leader,leaving,likely,lunch,mass,master,mile,mix,model,mud,muscle,nearby,nearest,nest,newspaper,nobody,observe,pacific,peace,plate,plenty,popular,powerful,push,railroad,rapidly,root,rubber,sad,sail,save,score,seeing,serious,service,sheet,shop,silent,smell,smoke,smooth,source,spell,storm,structure,supper,support,sweet,swim,telephone,texas,threw,throw,tone,tool,track,trail,understanding,upper,view,wagon,western,whatever,wheat,whenever,whom,win,wonderful,wore,ability,agree,ants,asia,asleep,attack,balance,bat,battle,Ben,block,bow,brain,brave,bridge,cave,charge,chemical,China,clay,climb,composition,congress,copper,crew,cup,daughter,design,determine,direct,discuss,division,drawn,earlier,eaten,education,enemy,enter,equipment,escape,european,excited,expression,extra,factory,feathers,fellow,fighting,fought,frank,freedom,funny,fur,growth,hall,health,highest,hunt,including,increase,indicate,individual,japanese,kill,laugh,library,lift,lion,local,lose,lovely,lying,magic,mama,manner,mark,may,mostly,national,neighbor,ordinary,parallel,park,particularly,pencil,perfect,planet,planned,pleasant,pocket,police,political,post,potatoes,price,printed,program,property,prove,remain,riding,roll,roman,roof,rough,scene,search,secret,series,serve,settlers,shinning,shut,signal,sir,skill,smallest,social,softly,struck,studying,success,suit,sunlight,swimming,taste,taught,thank,tip,title,tongue,valuable,vast,vegetable,wash,weak,activity,alaska,appearance,article,aunt,automobile,avoid,basket,birthday,cage,cake,Canada,central,character,Charles,chicken,chosen,club,cook,court,cream,cutting,daily,darkness,diagram,Dick,disappear,doubt,dozen,dream,driving,effort,establish,exact,excitement,fifteen,flag,flies,football,foreign,frame,frequently,frighten,function,gate,gently,gradually,harder,hide,hurried,identity,importance,impossible,india,invented,italian,jar,journey,joy,lesson,lincoln,lips,log,london,loose,massage,minerals,outer,paint,papa,paris,particles,personal,physical,pie,pipe,pole,pond,progress,quarter,rays,recent,recognize,replace,rhythm,richard,robert,rod,ruler,safety,sally,sang,setting,shells,sick,situation,slightly,spain,spirit,steady,stepped,strike,successful,sudden,sum,terrible,tie,traffic,unusual,volume,whale,wise,yesterday,account,allow,anywhere,attached,audience,available,balloon,bare,bark,begun,bent,biggest,bill,blank,blew,breathing,butter,cap,carbon,card,chain,cheese,chest,Chicago,choice,circus,citizen,classroom,college,consist,continent,conversation,courage,cowboy,creature,date,depend,differ,discovery,disease,duck,due,Dutch,entirely,environment,exclaimed,factor,fog,forgot,forgotten,frozen,fuel,furniture,gather,gentle,globe,grandfather,greatly,helpful,hidden,honor,husband,involved,japan,jet,jimmy,layers,leaf,leather,load,lonely,louis,march,meal,medicine,merely,mice,molecular,musical,native,negro,noon,occur,orange,ought,pack,partly,pet,pine,pink,pitch,pool,prepare,press,prevent,pure,queen,rabbit,ranch,realize,receive,recently,rice,rising,rocket,saturday,saved,shade,shadow,shirt,shoot,shorter,silence,slipped,smith,snake,somewhere,spoken,standard,straw,strip,substance,suggest,sunday,teach,tears,thirty,thomas,thread,throat,tight,tin,triangle,truth,union,warn,whispered,wool,aid,aloud,andy,anyway,arrow,aside,atomic,author,basis,bean,becoming,Betsy,bicycle,blanket,brush,buffalo,burn,burst,bush,Carlos,collect,colony,combination,combine,comfortable,complex,composed,concerned,connected,construction,couple,create,curious,dig,dirt,distant,dot,edward,elephant,etc.,evidence,examine,excellent,failed,fallen,fastened,feature,fed,gain,graph,hearing,highway,improve,influence,july,june,lack,lamp,locate,luck,mail,married,mighty,mirror,mississippi,motor,mouse,needle,nodded,numeral,offer,oldest,operation,orbit,organized,outline,pain,pan,pen,piano,pictured,pig,pile,planning,pony,principal,production,refer,religious,repeat,research,respect,review,route,silk,slept,spite,stretch,stronger,stuck,swing,task,tax,tea,tent,thee,theory,thrown,tonight,topic,tower,transportation,trick,underline,unknown,upward,virginia,waste,wherever,willing,worry,worse,youth,accept,accident,active,additional,adjective,affect,alice,alphabet,announced,anybody,april,arrange,australia,aware,badly,bee,belt,bite,blind,bound,castle,characteristic,Columbus,compass,consonant,curve,definition,dish,Don,driven,dug,earn,eddy,eventually,explore,fairly,fewer,fifth,florida,gasoline,gift,grade,halfway,hang,headed,herd,hollow,income,industrial,introduced,johnson,jones,judge,loss,lucky,machinery,mad,magnet,mars,military,mistake,mood,nails,naturally,negative,obtain,origin,owner,passage,percent,perfectly,pilot,pleasure,plural,plus,poet,porch,pot,powder,previous,primitive,principle,prize,purple,raw,reader,remove,salmon,screen,seldom,select,society,somebody,specific,spider,sport,stairs,stared,steep,stomach,stove,stranger,struggle,surrounded,swam,syllable,tank,tape,thou,tightly,tim,trace,tribe,trunk,tv,universe,visitor,vote,weigh,wilson,younger,zero,aboard,accurate,actual,adventure,apartment,applied,appropriate,arrive,atom,Bay,behavior,bend,bet,birth,brass,breathe,brief,buried,camera,captured,chamber,command,crack,Daniel,David,dawn,declared,diameter,difficulty,dirty,dull,duty,eager,eleven,engineer,equally,equator,fierce,firm,fix,flame,former,forty,fox,fred,frog,fully,goose,gravity,greece,guard,gulf,handsome,harbor,hay,hello,horn,hospital,ill,interior,jeff,jungle,labor,limited,location,mainly,managed,maria,mental,mixture,movie,nearer,nervous,noted,october,officer,ohio,opinion,opportunity,organization,package,pale,plastic,pole,port,pour,private,properly,protection,pupil,rear,refused,roar,rome,russia,russian,saddle,settle,shelf,shelter,shine,sink,slabs,slave,somehow,split,stems,stock,swept,thy,tide,torn,troops,tropical,typical,unhappy,vertical,victory,voyage,welcome,whistle,widely,worried,wrapped,writer,acres,adult,advice,arrangement,attempt,august,autumn,border,breeze,brick,calm,canal,Casey,cast,chose,claws,coach,constantly,contrast,cookies,customs,damage,Danny,deeply,depth,discussion,doll,donkey,egypt,ellen,essential,exchange,exist,explanation,facing,film,finest,fireplace,floating,folks,fort,garage,grabbed,grandmother,habit,happily,harry,heading,hunter,illinois,image,independent,instant,january,kids,label,lee,lungs,manufacturing,martin,mathematics,melted,memory,mill,mission,monkey,mount,mysterious,neighborhood,norway,nuts,occasionally,official,ourselves,palace,pennsylvania,philadelphia,plates,poetry,policeman,positive,possibly,practical,pride,promised,recall,relationship,remarkable,require,rhyme,rocky,rubbed,rush,sale,satellites,satisfied,scared,selection,shake,shaking,shallow,shout,silly,simplest,slight,slip,slope,soap,solar,species,spin,stiff,swung,tales,thumb,tobacco,toy,trap,treated,tune,university,vapor,vessels,wealth,wolf,zoo -------------------------------------------------------------------------------- /lib/rsd.xml.php: -------------------------------------------------------------------------------- 1 | 6 | 7 | oelna/microblog 8 | https://github.com/oelna/microblog 9 | = $config['url'] ?> 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/twitter_api.php: -------------------------------------------------------------------------------- 1 | 11 | * @license MIT License 12 | * @version 1.0.4 13 | * @link http://github.com/j7mbo/twitter-api-php 14 | */ 15 | class TwitterAPIExchange 16 | { 17 | /** 18 | * @var string 19 | */ 20 | private $oauth_access_token; 21 | 22 | /** 23 | * @var string 24 | */ 25 | private $oauth_access_token_secret; 26 | 27 | /** 28 | * @var string 29 | */ 30 | private $consumer_key; 31 | 32 | /** 33 | * @var string 34 | */ 35 | private $consumer_secret; 36 | 37 | /** 38 | * @var array 39 | */ 40 | private $postfields; 41 | 42 | /** 43 | * @var string 44 | */ 45 | private $getfield; 46 | 47 | /** 48 | * @var mixed 49 | */ 50 | protected $oauth; 51 | 52 | /** 53 | * @var string 54 | */ 55 | public $url; 56 | 57 | /** 58 | * @var string 59 | */ 60 | public $requestMethod; 61 | 62 | /** 63 | * The HTTP status code from the previous request 64 | * 65 | * @var int 66 | */ 67 | protected $httpStatusCode; 68 | 69 | /** 70 | * Create the API access object. Requires an array of settings:: 71 | * oauth access token, oauth access token secret, consumer key, consumer secret 72 | * These are all available by creating your own application on dev.twitter.com 73 | * Requires the cURL library 74 | * 75 | * @throws \RuntimeException When cURL isn't loaded 76 | * @throws \InvalidArgumentException When incomplete settings parameters are provided 77 | * 78 | * @param array $settings 79 | */ 80 | public function __construct(array $settings) 81 | { 82 | if (!function_exists('curl_init')) 83 | { 84 | throw new RuntimeException('TwitterAPIExchange requires cURL extension to be loaded, see: http://curl.haxx.se/docs/install.html'); 85 | } 86 | 87 | if (!isset($settings['oauth_access_token']) 88 | || !isset($settings['oauth_access_token_secret']) 89 | || !isset($settings['consumer_key']) 90 | || !isset($settings['consumer_secret'])) 91 | { 92 | throw new InvalidArgumentException('Incomplete settings passed to TwitterAPIExchange'); 93 | } 94 | 95 | $this->oauth_access_token = $settings['oauth_access_token']; 96 | $this->oauth_access_token_secret = $settings['oauth_access_token_secret']; 97 | $this->consumer_key = $settings['consumer_key']; 98 | $this->consumer_secret = $settings['consumer_secret']; 99 | } 100 | 101 | /** 102 | * Set postfields array, example: array('screen_name' => 'J7mbo') 103 | * 104 | * @param array $array Array of parameters to send to API 105 | * 106 | * @throws \Exception When you are trying to set both get and post fields 107 | * 108 | * @return TwitterAPIExchange Instance of self for method chaining 109 | */ 110 | public function setPostfields(array $array) 111 | { 112 | if (!is_null($this->getGetfield())) 113 | { 114 | throw new Exception('You can only choose get OR post fields (post fields include put).'); 115 | } 116 | 117 | if (isset($array['status']) && substr($array['status'], 0, 1) === '@') 118 | { 119 | $array['status'] = sprintf("\0%s", $array['status']); 120 | } 121 | 122 | foreach ($array as $key => &$value) 123 | { 124 | if (is_bool($value)) 125 | { 126 | $value = ($value === true) ? 'true' : 'false'; 127 | } 128 | } 129 | 130 | $this->postfields = $array; 131 | 132 | // rebuild oAuth 133 | if (isset($this->oauth['oauth_signature'])) 134 | { 135 | $this->buildOauth($this->url, $this->requestMethod); 136 | } 137 | 138 | return $this; 139 | } 140 | 141 | /** 142 | * Set getfield string, example: '?screen_name=J7mbo' 143 | * 144 | * @param string $string Get key and value pairs as string 145 | * 146 | * @throws \Exception 147 | * 148 | * @return \TwitterAPIExchange Instance of self for method chaining 149 | */ 150 | public function setGetfield($string) 151 | { 152 | if (!is_null($this->getPostfields())) 153 | { 154 | throw new Exception('You can only choose get OR post / post fields.'); 155 | } 156 | 157 | $getfields = preg_replace('/^\?/', '', explode('&', $string)); 158 | $params = array(); 159 | 160 | foreach ($getfields as $field) 161 | { 162 | if ($field !== '') 163 | { 164 | list($key, $value) = explode('=', $field); 165 | $params[$key] = $value; 166 | } 167 | } 168 | 169 | $this->getfield = '?' . http_build_query($params, '', '&'); 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Get getfield string (simple getter) 176 | * 177 | * @return string $this->getfields 178 | */ 179 | public function getGetfield() 180 | { 181 | return $this->getfield; 182 | } 183 | 184 | /** 185 | * Get postfields array (simple getter) 186 | * 187 | * @return array $this->postfields 188 | */ 189 | public function getPostfields() 190 | { 191 | return $this->postfields; 192 | } 193 | 194 | /** 195 | * Build the Oauth object using params set in construct and additionals 196 | * passed to this method. For v1.1, see: https://dev.twitter.com/docs/api/1.1 197 | * 198 | * @param string $url The API url to use. Example: https://api.twitter.com/1.1/search/tweets.json 199 | * @param string $requestMethod Either POST or GET 200 | * 201 | * @throws \Exception 202 | * 203 | * @return \TwitterAPIExchange Instance of self for method chaining 204 | */ 205 | public function buildOauth($url, $requestMethod) 206 | { 207 | if (!in_array(strtolower($requestMethod), array('post', 'get', 'put', 'delete'))) 208 | { 209 | throw new Exception('Request method must be either POST, GET or PUT or DELETE'); 210 | } 211 | 212 | $consumer_key = $this->consumer_key; 213 | $consumer_secret = $this->consumer_secret; 214 | $oauth_access_token = $this->oauth_access_token; 215 | $oauth_access_token_secret = $this->oauth_access_token_secret; 216 | 217 | $oauth = array( 218 | 'oauth_consumer_key' => $consumer_key, 219 | 'oauth_nonce' => time(), 220 | 'oauth_signature_method' => 'HMAC-SHA1', 221 | 'oauth_token' => $oauth_access_token, 222 | 'oauth_timestamp' => time(), 223 | 'oauth_version' => '1.0' 224 | ); 225 | 226 | $getfield = $this->getGetfield(); 227 | 228 | if (!is_null($getfield)) 229 | { 230 | $getfields = str_replace('?', '', explode('&', $getfield)); 231 | 232 | foreach ($getfields as $g) 233 | { 234 | $split = explode('=', $g); 235 | 236 | /** In case a null is passed through **/ 237 | if (isset($split[1])) 238 | { 239 | $oauth[$split[0]] = urldecode($split[1]); 240 | } 241 | } 242 | } 243 | 244 | $postfields = $this->getPostfields(); 245 | 246 | if (!is_null($postfields)) { 247 | foreach ($postfields as $key => $value) { 248 | $oauth[$key] = $value; 249 | } 250 | } 251 | 252 | $base_info = $this->buildBaseString($url, $requestMethod, $oauth); 253 | $composite_key = rawurlencode($consumer_secret) . '&' . rawurlencode($oauth_access_token_secret); 254 | $oauth_signature = base64_encode(hash_hmac('sha1', $base_info, $composite_key, true)); 255 | $oauth['oauth_signature'] = $oauth_signature; 256 | 257 | $this->url = $url; 258 | $this->requestMethod = $requestMethod; 259 | $this->oauth = $oauth; 260 | 261 | return $this; 262 | } 263 | 264 | /** 265 | * Perform the actual data retrieval from the API 266 | * 267 | * @param boolean $return If true, returns data. This is left in for backward compatibility reasons 268 | * @param array $curlOptions Additional Curl options for this request 269 | * 270 | * @throws \Exception 271 | * 272 | * @return string json If $return param is true, returns json data. 273 | */ 274 | public function performRequest($return = true, $curlOptions = array()) 275 | { 276 | if (!is_bool($return)) 277 | { 278 | throw new Exception('performRequest parameter must be true or false'); 279 | } 280 | 281 | $header = array($this->buildAuthorizationHeader($this->oauth), 'Expect:'); 282 | 283 | $getfield = $this->getGetfield(); 284 | $postfields = $this->getPostfields(); 285 | 286 | if (in_array(strtolower($this->requestMethod), array('put', 'delete'))) 287 | { 288 | $curlOptions[CURLOPT_CUSTOMREQUEST] = $this->requestMethod; 289 | } 290 | 291 | $options = $curlOptions + array( 292 | CURLOPT_HTTPHEADER => $header, 293 | CURLOPT_HEADER => false, 294 | CURLOPT_URL => $this->url, 295 | CURLOPT_RETURNTRANSFER => true, 296 | CURLOPT_TIMEOUT => 10, 297 | ); 298 | 299 | if (!is_null($postfields)) 300 | { 301 | $options[CURLOPT_POSTFIELDS] = http_build_query($postfields, '', '&'); 302 | } 303 | else 304 | { 305 | if ($getfield !== '') 306 | { 307 | $options[CURLOPT_URL] .= $getfield; 308 | } 309 | } 310 | 311 | $feed = curl_init(); 312 | curl_setopt_array($feed, $options); 313 | $json = curl_exec($feed); 314 | 315 | $this->httpStatusCode = curl_getinfo($feed, CURLINFO_HTTP_CODE); 316 | 317 | if (($error = curl_error($feed)) !== '') 318 | { 319 | curl_close($feed); 320 | 321 | throw new \Exception($error); 322 | } 323 | 324 | curl_close($feed); 325 | 326 | return $json; 327 | } 328 | 329 | /** 330 | * Private method to generate the base string used by cURL 331 | * 332 | * @param string $baseURI 333 | * @param string $method 334 | * @param array $params 335 | * 336 | * @return string Built base string 337 | */ 338 | private function buildBaseString($baseURI, $method, $params) 339 | { 340 | $return = array(); 341 | ksort($params); 342 | 343 | foreach($params as $key => $value) 344 | { 345 | $return[] = rawurlencode($key) . '=' . rawurlencode($value); 346 | } 347 | 348 | return $method . "&" . rawurlencode($baseURI) . '&' . rawurlencode(implode('&', $return)); 349 | } 350 | 351 | /** 352 | * Private method to generate authorization header used by cURL 353 | * 354 | * @param array $oauth Array of oauth data generated by buildOauth() 355 | * 356 | * @return string $return Header used by cURL for request 357 | */ 358 | private function buildAuthorizationHeader(array $oauth) 359 | { 360 | $return = 'Authorization: OAuth '; 361 | $values = array(); 362 | 363 | foreach($oauth as $key => $value) 364 | { 365 | if (in_array($key, array('oauth_consumer_key', 'oauth_nonce', 'oauth_signature', 366 | 'oauth_signature_method', 'oauth_timestamp', 'oauth_token', 'oauth_version'))) { 367 | $values[] = "$key=\"" . rawurlencode($value) . "\""; 368 | } 369 | } 370 | 371 | $return .= implode(', ', $values); 372 | return $return; 373 | } 374 | 375 | /** 376 | * Helper method to perform our request 377 | * 378 | * @param string $url 379 | * @param string $method 380 | * @param string $data 381 | * @param array $curlOptions 382 | * 383 | * @throws \Exception 384 | * 385 | * @return string The json response from the server 386 | */ 387 | public function request($url, $method = 'get', $data = null, $curlOptions = array()) 388 | { 389 | if (strtolower($method) === 'get') 390 | { 391 | $this->setGetfield($data); 392 | } 393 | else 394 | { 395 | $this->setPostfields($data); 396 | } 397 | 398 | return $this->buildOauth($url, $method)->performRequest(true, $curlOptions); 399 | } 400 | 401 | /** 402 | * Get the HTTP status code for the previous request 403 | * 404 | * @return integer 405 | */ 406 | public function getHttpStatusCode() 407 | { 408 | return $this->httpStatusCode; 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /snippets/footer.snippet.php: -------------------------------------------------------------------------------- 1 | 12 | 22 | -------------------------------------------------------------------------------- /snippets/header.snippet.php: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | = empty($config['microblog_account']) ? "" : $config['microblog_account'] . "'s "; ?>micro.blog= $title_suffix ?> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /snippets/nav.snippet.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Timeline 4 | New Status 5 | Login 6 | 7 | 8 | -------------------------------------------------------------------------------- /templates/loginform.inc.php: -------------------------------------------------------------------------------- 1 | 'error', 18 | 'message' => 'You entered wrong user credentials. Please try again.' 19 | ); 20 | } 21 | } 22 | 23 | $title_suffix = 'login'; 24 | require(ROOT.DS.'snippets'.DS.'header.snippet.php'); 25 | 26 | ?> 27 | 28 | 29 | Please enter your login information. 30 | 31 | A recovery link has been sent to your email address. (Please also check Spam!) 32 | 33 | 34 | = $message['message'] ?> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 48 | Use Passkey 49 | Forgot password 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /templates/postform.inc.php: -------------------------------------------------------------------------------- 1 | 0) { 17 | $message = array( 18 | 'status' => 'success', 19 | 'message' => 'Successfully posted status #'.$id 20 | ); 21 | 22 | // handle files 23 | if(!empty($_FILES['attachments'])) { 24 | attach_uploaded_files($_FILES['attachments'], $id); 25 | } 26 | 27 | rebuild_feeds(); 28 | 29 | if($config['activitypub'] == true) activitypub_notify_followers($id); 30 | if(isset($config['at_enabled']) && $config['at_enabled'] == true) at_post_status($id); 31 | if($config['ping'] == true) ping_microblog(); 32 | /* 33 | if($config['crosspost_to_twitter'] == true) { 34 | $twitter_response = json_decode(twitter_post_status($_POST['content']), true); 35 | 36 | if(!empty($twitter_response['errors'])) { 37 | $message['message'] .= ' (But crossposting to twitter failed!)'; 38 | } 39 | } 40 | */ 41 | 42 | header('Location: '.$config['url']); 43 | die(); 44 | } 45 | } 46 | 47 | $title_suffix = 'new post'; 48 | require(ROOT.DS.'snippets'.DS.'header.snippet.php'); 49 | 50 | ?> 51 | 52 | 53 | 54 | = $message['message'] ?> 55 | 56 | 57 | 58 | 59 | 60 | Add Files 61 | Add Files 62 | 63 | = $config['max_characters'] ?> 64 | 65 | 66 | 67 | 68 | 69 | 70 |
Please enter your login information.
A recovery link has been sent to your email address. (Please also check Spam!)
= $message['message'] ?>
= $config['max_characters'] ?>