├── .gitignore ├── CHANGELOG ├── LICENSE ├── README.md ├── composer.json ├── config.php.sample ├── inc ├── common.php ├── content.php ├── debug.php ├── media.php └── twitter.php └── index.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | composer.lock 3 | config.php 4 | secret.txt 5 | vendor/ 6 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | * Thu May 24 2018 Scott Merrill - 1.1.0 4 | - support more indieweb post types than just reposts and replies 5 | - better timezone handling 6 | - support configurable token endpoints 7 | - reposts and replies can interact with their source URLs 8 | - perform post type discovery 9 | - use "tweet_mode=extended" to get full tweet text 10 | - add Twitter silo support 11 | - handle media uploads more fully 12 | 13 | 14 | * Fri Apr 20 2018 Scott Merrill - 1.0.0 15 | - 1.0 release 16 | - support most of the Micropub spec 17 | - tested against micropub.rocks 18 | - use "name" property as article titles 19 | 20 | * Fri Apr 13 2018 Scott Merrill - 0.9 21 | - handle all content and media actions 22 | 23 | * Tue Mar 27 2018 Scott Merrill - 0.0.1 24 | - initial commit 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | micropub is licensed under Zero Clause BSD (0BSD): 2 | 3 | Copyright (C) 2018 by Scott Merrill 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # micropub 2 | a minimal PHP micropub endpoint for static sites, with media support 3 | 4 | This is a micropub solution for static sites generated with [Hugo](https://gohugo.io/). It will accept most actions as defined in the [Micropub standard](https://www.w3.org/TR/micropub/), and upon completion will invoke Hugo and rebuild your site. 5 | 6 | This is based heavily off of the following projects: 7 | * [rhiaro's MVP micropub](https://rhiaro.co.uk/2015/04/minimum-viable-micropub) 8 | * [dgold's Nanopub](https://github.com/dg01d/nanopub/) 9 | * [aaronpk's MVP Media Endpoint](https://gist.github.com/aaronpk/4bee1753688ca9f3036d6f31377edf14) 10 | 11 | This works **for me**, following the principles of [self dog fooding](https://indieweb.org/selfdogfood). Rather then develop a universal widget that might work for all possible implementations, I built what I needed. Hopefully this serves as an inspiration for others, in the same way that those projects linked above heavily inspired me. 12 | 13 | ## Installation 14 | If you're using Hugo, you can simply clone this repo into the `/public` directory of your active website. Files that exist in `/public` but which do not exist in your `/content` or `/static` directories will not be overwritten. 15 | 16 | Alternately, you could clone this to the `/static` directory, and have Hugo (re)copy is into place with every site build. 17 | 18 | ``` 19 | git clone https://github.com/skpy/micropub.git 20 | cd micropub 21 | php composer.phar install 22 | cp config.php.sample config.php 23 | vi config.php 24 | ``` 25 | Edit the config values as needed for your site. 26 | 27 | Add the necessary markup to your HTML templates to declare your Micropub endpoint: 28 | ``` 29 | 30 | ``` 31 | 32 | Now point a Micropub client at your site, and start creating content! 33 | 34 | ### Syndication 35 | Content you create can be syndicated to external services. Right now, only Twitter is supported; but adding additional syndication targets should be straightforward. 36 | 37 | Each syndication target is required to have configuration declared in the `syndication` array in `config.php`. Then, each syndication target should have a function `syndication_`, where matches the name of the array key in `config.php`. Each such function is expected to return the URL of the syndicated copy of this post, which will be added to the front matter of the post. 38 | 39 | ### Source URLs 40 | Replies, reposts, bookmarks, etc all define a source URL. This server can interact with those sources on a per-target basis. Right now, the only supported source is Twitter. If the source of a reply, repost, or bookmark is a Tweet, the original tweet will be retreived, and stored in the front matter of the post. Your theme may then elect to use this as needed. In this way, we can preserve historical context of your activities, and allow you to display referenced data as you need. 41 | 42 | Additional sources can be added, much like syndication. To define a new source, create a new function that matches the format `_`. Convert all dots in the domain name to underscores. For example, the Twitter source functions use `in_reply_to_twitter_com` and `repost_of_twitter_com` and `bookmark_of_twitter_com`. 43 | 44 | The Twitter source also defines `_m_twitter_com`, which are simple wrappers to ensure that this functionality works when using mobile-friendly URLs. 45 | 46 | See `inc/twitter.php` for the implementation details. 47 | 48 | ## How I use this 49 | I have my Hugo site in `/var/www/skippy.net`. I have my [web server](https://caddyserver.com/) configured to use `/var/www/skippy.net/public` as the document root of my site. All of `/var/www/skippy.net/content` and `/var/www/skippy.net/static` are owned by the `www-data` user, to ensure that content can be created, edited, and deleted through Micropub without permission problems. 50 | 51 | When I create a new post, Micropub will generate the file in `/var/www/skippy.net/content/`, and then invoke Hugo, which will recreate all the content in `/var/www/skippy.net/public`. No extra steps are required, and the new content is available. 52 | 53 | I am making heavy use of [Hugo data files](https://gohugo.io/templates/data-templates/) with this Micropub server. Notes, photos, replies, reposts, bookmarks, and likes are all stored as YAML arrays in files in the `/data` directory. This allows new content to be appended quickly, and reduces the number of content files that Hugo needs to parse when building the site. 54 | 55 | At this time, editing and deleting any content stored in a data file is **not supported**. Editing and deleting is only supported for articles. 56 | 57 | The media endpoint automatically generates thumbnails with a maximum width of 200 pixels. The array of links to these is stored in the `$properties['thumbnail']` property. 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "symfony/yaml": "4.0.*", 4 | "p3k/micropub": "*", 5 | "abraham/twitteroauth": "^0.7.4" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /config.php.sample: -------------------------------------------------------------------------------- 1 | 'https://' . $_SERVER['HTTP_HOST'] .'/', 10 | 11 | # the base path of the site's docroot, with trailing slash 12 | 'base_path' => '/var/www/html/', 13 | 14 | # the name of the sub-directory for images, with trailing slash. 15 | # we'll create sub-directories of the form 'year/month/'. 16 | 'upload_path' => 'images/', 17 | 18 | # the max pixel width of uploaded images. 19 | 'max_image_width' => 800, 20 | 21 | # the path to the Hugo site. DO NOT include "content/", we'll handle that. 22 | # trailing slash required. 23 | 'source_path' => '/var/www/skippy/', 24 | 25 | # different types of content may have different paths. 26 | # by default, articles are in the root of the /content/ directory, so 27 | # are not included here. 28 | # Notes, reposts, replies, etc are being stored as Hugo data files 29 | # in the /data directory. No need to prepend "/data" to these paths. 30 | 'content_paths' => array( 31 | 'bookmark-of' => 'bookmarks', 32 | 'in-reply-to' => date('Y/m'), 33 | 'like-of' => 'likes', 34 | 'note' => date('Y/m'), 35 | 'photo' => date('Y/m'), 36 | 'repost-of' => date('Y/m'), 37 | 'rsvp' => 'rsvp', 38 | ), 39 | 40 | # I am storing all photos, reposts and replies as notes. So I need a 41 | # way to tell Hugo to use the "note" templates for these items. This 42 | # override controls that. 43 | 'content_overrides' => array( 44 | 'in-reply-to' => 'note', 45 | 'photo' => 'note', 46 | 'repost-of' => 'note', 47 | ), 48 | 49 | # whether or not to copy uploaded files to the source /static/ directory. 50 | 'copy_uploads_to_source' => true, 51 | 52 | # an external micropub media endpoint to use. 53 | # 'media_endpoint' => 'https://example.com/my-media-endpoint/', 54 | 55 | # an array of syndication targets; each of which should contain the 56 | # necessary credentials. 57 | 'syndication' => array( 58 | 'twitter' => array( 'key' => 'CONSUMER_KEY', 59 | 'secret' => 'CONSUMER_SECRET', 60 | 'token' => 'ACCESS_TOKEN', 61 | 'token_secret' => 'ACCESS_TOKEN_SECRET', 62 | 'prefix' => 'I just posted ', 63 | ), 64 | ), 65 | 66 | # some Micropub clients don't set syndication targets for some actions, 67 | # but we may want to syndicate some stuff all the time. For each post 68 | # kind, define an array of mandatory syndication targets. 69 | 'always_syndicate' => array( 70 | 'repost-of' => array( 'twitter' ), 71 | 'in-reply-to' => array( 'twitter' ), 72 | ), 73 | 74 | # the IndieAuth token endpoint to use 75 | 'token_endpoint' => 'https://tokens.indieauth.com/token', 76 | 77 | # the command used to build the site 78 | 'command' => '/var/www/bin/hugo --quiet --config /var/www/skippy/config.yaml -s /var/www/skippy/ -d /var/www/html/', 79 | ); 80 | 81 | return $config; 82 | ?> 83 | -------------------------------------------------------------------------------- /inc/common.php: -------------------------------------------------------------------------------- 1 | '200 OK', 5 | '201' => '201 Created', 6 | '202' => '202 Accepted', 7 | '400' => '400 Bad Request', 8 | '401' => '401 Unauthorized', 9 | '403' => '403 Forbidden', 10 | '409' => '409 Conflict', 11 | '413' => '413 Payload Too Large', 12 | '415' => '415 Unsupported Media Type', 13 | '502' => '502 Bad Gateway', 14 | ); 15 | return $http_codes[$code]; 16 | } 17 | 18 | function quit ($code = 400, $error = '', $description = 'An error occurred.', $location = '') { 19 | $code = (int) $code; 20 | header("HTTP/1.1 " . http_status($code)); 21 | if ( $code >= 400 ) { 22 | echo json_encode(['error' => $error, 'error_description' => $description]); 23 | } elseif ($code == 200 || $code == 201 || $code == 202) { 24 | if (!empty($location)) { 25 | header('Location: ' . $location); 26 | echo $location; 27 | } 28 | } 29 | die(); 30 | } 31 | 32 | function show_info() { 33 | echo '

This is a micropub endpoint.

'; 34 | die(); 35 | } 36 | 37 | function parse_request() { 38 | if ( strtolower($_SERVER['CONTENT_TYPE']) == 'application/json' || strtolower($_SERVER['HTTP_CONTENT_TYPE']) == 'application/json' ) { 39 | $request = \p3k\Micropub\Request::createFromJSONObject(json_decode(file_get_contents('php://input'), true)); 40 | } else { 41 | $request = \p3k\Micropub\Request::createFromPostArray($_POST); 42 | } 43 | if($request->error) { 44 | quit(400, $request->error_property, $request->error_description); 45 | } 46 | return $request; 47 | } 48 | 49 | /** 50 | * getallheaders() replacement for nginx 51 | * 52 | * Replaces the getallheaders function which relies on Apache 53 | * 54 | * @return array incoming headers from _POST 55 | */ 56 | if (!function_exists('getallheaders')) { 57 | function getallheaders() { 58 | $headers = []; 59 | foreach ($_SERVER as $name => $value) { 60 | if (substr($name, 0, 5) == 'HTTP_') { 61 | $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; 62 | } 63 | } 64 | return $headers; 65 | } 66 | } 67 | 68 | /** 69 | * Validate incoming requests, using IndieAuth 70 | * 71 | * This section largely adopted from rhiaro 72 | * 73 | * @param array $token the authorization token to check 74 | * @param string $me the site to authorize 75 | * 76 | * @return boolean true if authorised 77 | */ 78 | function indieAuth($endpoint, $token, $me = '') { 79 | /** 80 | * Check token is valid 81 | */ 82 | if ( $me == '' ) { $me = $_SERVER['HTTP_HOST']; } 83 | $ch = curl_init($endpoint); 84 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 85 | curl_setopt($ch, CURLOPT_HTTPHEADER, 86 | Array("Accept: application/json","Authorization: $token")); 87 | $token_response = strval(curl_exec($ch)); 88 | curl_close($ch); 89 | if (empty($token_response)) { 90 | # strval(FALSE) is an empty string 91 | quit(502, 'connection_problem', 'Unable to connect to token service'); 92 | } 93 | $response = json_decode($token_response, true, 2); 94 | if (!is_array($response) || json_last_error() !== \JSON_ERROR_NONE) { 95 | parse_str($token_response, $response); 96 | } 97 | if (empty($response) || isset($response['error']) || ! isset($response['me']) || ! isset($response['scope']) ) { 98 | quit(401, 'insufficient_scope', 'The request lacks authentication credentials'); 99 | } elseif ($response['me'] != $me) { 100 | quit(401, 'insufficient_scope', 'The request lacks valid authentication credentials'); 101 | } elseif (is_array($response['scope']) && !in_array('create', $response['scope']) && !in_array('post', $response['scope'])) { 102 | quit(403, 'forbidden', 'Client does not have access to this resource'); 103 | } elseif (FALSE === stripos($response['scope'], 'create')) { 104 | quit(403, 'Forbidden', 'Client does not have access to this resource'); 105 | } 106 | // we got here, so all checks passed. return true. 107 | return true; 108 | } 109 | 110 | # respond to queries about config and/or syndication 111 | function show_config($show = 'all') { 112 | global $config; 113 | $syndicate_to = array(); 114 | if ( ! empty($config['syndication'])) { 115 | foreach ($config['syndication'] as $k => $v) { 116 | $syndicate_to[] = array('uid' => $k, 'name' => $k); 117 | } 118 | } 119 | 120 | $media_endpoint = isset($config['media_endpoint']) ? 121 | $config['media_endpoint'] : 122 | ($config['base_url'] . 'micropub/index.php'); 123 | $conf = array("media-endpoint" => $media_endpoint); 124 | if ( ! empty($syndicate_to) ) { 125 | $conf['syndicate-to'] = $syndicate_to; 126 | } 127 | 128 | header('Content-Type: application/json'); 129 | if ($show == "syndicate-to") { 130 | echo json_encode(array('syndicate-to' => $syndicate_to), 32 | 64 | 128 | 256); 131 | } else { 132 | echo json_encode($conf, 32 | 64 | 128 | 256); 133 | } 134 | exit; 135 | } 136 | 137 | function build_site() { 138 | global $config; 139 | exec( $config['command']); 140 | } 141 | 142 | # PHP handles arrays of file uploads differently from a single file upload. 143 | # So we need to normalize this into a common structure upon which we can act. 144 | function normalize_files_array($files) { 145 | $result = []; 146 | if (isset($files['tmp_name']) && is_array($files['tmp_name'])) { 147 | # we have an array of one or more elements. 148 | foreach (array_keys($files['tmp_name']) as $key) { 149 | $result[] = [ 150 | 'tmp_name' => $files['tmp_name'][$key], 151 | 'size' => $files['size'][$key], 152 | 'error' => $files['error'][$key], 153 | 'name' => $files['name'][$key], 154 | 'type' => $files['type'][$key], 155 | ]; 156 | } 157 | } else { 158 | # make sure we return an array, so we can iterate over it 159 | $result = [ $files ]; 160 | } 161 | return $result; 162 | } 163 | 164 | function check_target_dir($target_dir) { 165 | if ( empty($target_dir)) { 166 | quit(400, 'unknown_dir', 'Unspecified directory'); 167 | } 168 | # make sure our upload directory exists 169 | if ( ! file_exists($target_dir) ) { 170 | # fail if we can't create the directory 171 | if ( FALSE === mkdir($target_dir, 0755, true) ) { 172 | quit(400, 'cannot_mkdir', 'The directory could not be created.'); 173 | } 174 | } 175 | } 176 | ?> 177 | -------------------------------------------------------------------------------- /inc/content.php: -------------------------------------------------------------------------------- 1 | $v) { 33 | if(!is_array($v)) { 34 | $v = [$v]; 35 | } 36 | $properties[$k] = $v; 37 | } 38 | $properties['content'] = [ trim($parts[2]) ]; 39 | return $properties; 40 | } 41 | 42 | # this function fetches the source of a post and returns a JSON 43 | # encoded object of it. 44 | function show_content_source($url, $properties = []) { 45 | $source = parse_file( get_source_from_url($url) ); 46 | $props = []; 47 | 48 | # the request may define specific properties to return, so 49 | # check for them. 50 | if ( ! empty($properties)) { 51 | foreach ($properties as $p) { 52 | if (array_key_exists($p, $source)) { 53 | $props[$p] = $source[$p]; 54 | } 55 | } 56 | } else { 57 | $props = parse_file( get_source_from_url($url) ); 58 | } 59 | header( "Content-Type: application/json"); 60 | print json_encode( [ 'properties' => $props ] ); 61 | die(); 62 | } 63 | 64 | # this takes a string and returns a slug. 65 | # I generally don't use non-ASCII items in titles, so this doesn't 66 | # worry about any of that. 67 | function slugify($string) { 68 | return strtolower( preg_replace("/[^-\w+]/", "", str_replace(' ', '-', $string) ) ); 69 | } 70 | 71 | # this takes an MF2 array of arrays and converts single-element arrays 72 | # into non-arrays. 73 | function normalize_properties($properties) { 74 | $props = []; 75 | foreach ($properties as $k => $v) { 76 | # we want the "photo" property to be an array, even if it's a 77 | # single element. Our Hugo templates require this. 78 | if ($k == 'photo') { 79 | $props[$k] = $v; 80 | } elseif (is_array($v) && count($v) === 1) { 81 | $props[$k] = $v[0]; 82 | } else { 83 | $props[$k] = $v; 84 | } 85 | } 86 | # MF2 defines "name" instead of title, but Hugo wants "title". 87 | # Only assign a title if the post has a name. 88 | if (isset($props['name'])) { 89 | $props['title'] = $props['name']; 90 | } 91 | return $props; 92 | } 93 | 94 | # this function is a router to other functions that can operate on the source 95 | # URLs of reposts, replies, bookmarks, etc. 96 | # $type = the indieweb type (https://indieweb.org/post-type-discovery) 97 | # $properties = array of front-matter properties for this post 98 | # $content = the content of this post (which may be an empty string) 99 | # 100 | function posttype_source_function($posttype, $properties, $content) { 101 | # replace all hyphens with underscores, for later use 102 | $type = str_replace('-', '_', $posttype); 103 | # get the domain of the site to which we are replying, and convert 104 | # all dots to underscores. 105 | $target = str_replace('.', '_', parse_url($properties[$posttype], PHP_URL_HOST)); 106 | # if a function exists for this type + target combo, call it 107 | if (function_exists("${type}_${target}")) { 108 | list($properties, $content) = call_user_func("${type}_${target}", $properties, $content); 109 | } 110 | return [$properties, $content]; 111 | } 112 | 113 | # this function accepts the properties of a post and 114 | # tries to perform post type discovery according to 115 | # https://indieweb.org/post-type-discovery 116 | # returns the MF2 post type 117 | function post_type_discovery($properties) { 118 | $vocab = array('rsvp', 119 | 'in-reply-to', 120 | 'repost-of', 121 | 'like-of', 122 | 'bookmark-of', 123 | 'photo'); 124 | foreach ($vocab as $type) { 125 | if (isset($properties[$type])) { 126 | return $type; 127 | } 128 | } 129 | # articles have titles, which Micropub defines as "name" 130 | if (isset($properties['name'])) { 131 | return 'article'; 132 | } 133 | # no other match? Must be a note. 134 | return 'note'; 135 | } 136 | 137 | # given an array of front matter and body content, return a full post 138 | # Articles are full Markdown files; everything else is just YAML blobs 139 | # to be appended to a data file. 140 | function build_post( $front_matter, $content) { 141 | ksort($front_matter); 142 | if ($front_matter['posttype'] == 'article') { 143 | return "---\n" . Yaml::dump($front_matter) . "---\n" . $content . "\n"; 144 | } else { 145 | $front_matter['content'] = $content; 146 | return Yaml::dump(array($front_matter), 2, 2); 147 | } 148 | } 149 | 150 | function write_file($file, $content, $overwrite = false) { 151 | # make sure the directory exists, in the event that the filename includes 152 | # a new sub-directory 153 | if ( ! file_exists(dirname($file))) { 154 | check_target_dir(dirname($file)); 155 | } 156 | if (file_exists($file) && ($overwrite == false) ) { 157 | quit(400, 'file_conflict', 'The specified file exists'); 158 | } 159 | if ( FALSE === file_put_contents( $file, $content ) ) { 160 | quit(400, 'file_error', 'Unable to open Markdown file'); 161 | } 162 | } 163 | 164 | function delete($request) { 165 | global $config; 166 | 167 | $filename = str_replace($config['base_url'], $config['base_path'], $request->url); 168 | if (false === unlink($filename)) { 169 | quit(400, 'unlink_failed', 'Unable to delete the source file.'); 170 | } 171 | # to delete a post, simply set the "published" property to "false" 172 | # and unlink the relevant .html file 173 | $json = json_encode( array('url' => $request->url, 174 | 'action' => 'update', 175 | 'replace' => [ 'published' => [ false ] ]) ); 176 | $new_request = \p3k\Micropub\Request::create($json); 177 | update($new_request); 178 | } 179 | 180 | function undelete($request) { 181 | # to undelete a post, simply set the "published" property to "true" 182 | $json = json_encode( array('url' => $request->url, 183 | 'action' => 'update', 184 | 'replace' => [ 'published' => [ true ] ]) ); 185 | $new_request = \p3k\Micropub\Request::create($json); 186 | update($new_request); 187 | } 188 | 189 | function update($request) { 190 | $filename = get_source_from_url($request->url); 191 | $original = parse_file($filename); 192 | foreach($request->update['replace'] as $key=>$value) { 193 | $original[$key] = $value; 194 | } 195 | foreach($request->update['add'] as $key=>$value) { 196 | if (!array_key_exists($key, $original)) { 197 | # adding a value to a new key. 198 | $original[$key] = $value; 199 | } else { 200 | # adding a value to an existing key 201 | $original[$key] = array_merge($original[$key], $value); 202 | } 203 | } 204 | foreach($request->update['delete'] as $key=>$value) { 205 | if (!is_array($value)) { 206 | # deleting a whole property 207 | if (isset($original[$value])) { 208 | unset($original[$value]); 209 | } 210 | } else { 211 | # deleting one or more elements from a property 212 | $original[$key] = array_diff($original[$key], $value); 213 | } 214 | } 215 | $content = $original['content'][0]; 216 | unset($original['content']); 217 | $original = normalize_properties($original); 218 | write_file($filename, build_post($original, $content), true); 219 | build_site(); 220 | } 221 | 222 | function create($request, $photos = []) { 223 | global $config; 224 | 225 | $mf2 = $request->toMf2(); 226 | # make a more normal PHP array from the MF2 JSON array 227 | $properties = normalize_properties($mf2['properties']); 228 | 229 | # pull out just the content, so that $properties can be front matter 230 | # NOTE: content may be in ['content'] or ['content']['html']. 231 | # NOTE 2: there may be NO content! 232 | if (isset($properties['content'])) { 233 | if (is_array($properties['content']) && isset($properties['content']['html'])) { 234 | $content = $properties['content']['html']; 235 | } else { 236 | $content = $properties['content']; 237 | } 238 | } else { 239 | $content = ''; 240 | } 241 | # ensure that the properties array doesn't contain 'content' 242 | unset($properties['content']); 243 | 244 | if (!empty($photos)) { 245 | # add uploaded photos to the front matter. 246 | if (!isset($properties['photo'])) { 247 | $properties['photo'] = $photos; 248 | } else { 249 | $properties['photo'] = array_merge($properties['photo'], $photos); 250 | } 251 | } 252 | if (!empty($properties['photo'])) { 253 | $properties['thumbnail'] = preg_replace('#-' . $config['max_image_width'] . '\.#', '-200.', $properties['photo']); 254 | } 255 | 256 | # figure out what kind of post this is. 257 | $properties['posttype'] = post_type_discovery($properties); 258 | 259 | # invoke any source-specific functions for this post type. 260 | # articles, notes, and photos don't really have "sources", other than 261 | # their own content. 262 | # replies, reposts, likes, bookmarks, etc, should reference source URLs 263 | # and may interact with those sources here. 264 | if (! in_array($properties['posttype'], ['article', 'note', 'photo'])) { 265 | list($properties, $content) = posttype_source_function($properties['posttype'], $properties, $content); 266 | } 267 | 268 | # all items need a date 269 | if (!isset($properties['date'])) { 270 | $properties['date'] = date('Y-m-d H:i:s'); 271 | } 272 | 273 | if (isset($properties['post-status'])) { 274 | if ($properties['post-status'] == 'draft') { 275 | $properties['published'] = false; 276 | } else { 277 | $properties['published'] = true; 278 | } 279 | unset($properties['post-status']); 280 | } else { 281 | # explicitly mark this item as published 282 | $properties['published'] = true; 283 | } 284 | 285 | # we need either a title, or a slug. 286 | # NOTE: MF2 defines "name" as the title value. 287 | if (!isset($properties['name']) && !isset($properties['slug'])) { 288 | # We will assign this a slug. 289 | # Hex value of seconds since UNIX epoch 290 | $properties['slug'] = dechex(date('U')); 291 | } 292 | 293 | # if we have a title but not a slug, generate a slug 294 | if (isset($properties['name']) && !isset($properties['slug'])) { 295 | $properties['slug'] = $properties['name']; 296 | } 297 | # make sure the slugs are safe. 298 | if (isset($properties['slug'])) { 299 | $properties['slug'] = slugify($properties['slug']); 300 | } 301 | 302 | # build the entire source file, with front matter and content for articles 303 | # or YAML blobs for notes, etc 304 | $file_contents = build_post($properties, $content); 305 | 306 | if ($properties['posttype'] == 'article') { 307 | # produce a file name for this post. 308 | $path = $config['source_path'] . 'content/'; 309 | $url = $config['base_url'] . $properties['slug'] . '/index.html'; 310 | $filename = $path . $properties['slug'] . '.md'; 311 | # write_file will default to NOT overwriting existing files, 312 | # so we don't need to check that here. 313 | write_file($filename, $file_contents); 314 | } else { 315 | # this content will be appended to a data file. 316 | # our config file defines the content_path of the desired file. 317 | $content_path = $config['content_paths'][$properties['posttype']]; 318 | $yaml_path = $config['source_path'] . 'data/' . $content_path . '.yaml'; 319 | $md_path = $config['source_path'] . 'content/' . $content_path . '.md'; 320 | $url = $config['base_url'] . $content_path . '/#' . $properties['slug']; 321 | check_target_dir(dirname($yaml_path)); 322 | check_target_dir(dirname($md_path)); 323 | if (! file_exists($yaml_path)) { 324 | # prep the YAML for our note which will follow 325 | file_put_contents($yaml_path, "---\nentries:\n"); 326 | } 327 | file_put_contents($yaml_path, $file_contents, FILE_APPEND); 328 | # now we need to create a Markdown file, so that Hugo will 329 | # build the file for public consumption. 330 | # NOTE: we may want to override the post type here, so that we 331 | # can use a singular Hugo theme for multiple post types. 332 | if (array_key_exists($properties['posttype'], $config['content_overrides'])) { 333 | $content_type = $config['content_overrides'][$properties['posttype']]; 334 | } else { 335 | $content_type = $properties['posttype']; 336 | } 337 | if (! file_exists($md_path)) { 338 | file_put_contents($md_path, "---\ntype: $content_type\n---\n"); 339 | } 340 | # we may need to create a _index.md file so that a section template 341 | # can be generated. If the content_path has any slashes in it, that 342 | # means that sub-directories are defined, and thus a section index 343 | # is required. 344 | if (FALSE !== strpos($content_path, '/')) { 345 | $section_path = dirname($config['source_path'] . 'content/' . $content_path) . '/_index.md'; 346 | file_put_contents($section_path, "---\ntype: $content_type\n---\n"); 347 | } 348 | } 349 | 350 | # build the site. 351 | build_site(); 352 | 353 | # allow the client to move on, while we syndicate this post 354 | header('HTTP/1.1 201 Created'); 355 | header('Location: ' . $url); 356 | 357 | # syndicate this post 358 | $syndication_targets = array(); 359 | # some post kinds may enforce syndication, even if the Micropub client 360 | # did not send an mp-syndicate-to parameter. This code finds those post 361 | # kinds and sets the mp-syndicate-to. 362 | if (isset($config['always_syndicate'])) { 363 | if (array_key_exists($properties['posttype'], $config['always_syndicate'])) { 364 | foreach ($config['always_syndicate'][$properties['posttype']] as $target) { 365 | $syndication_targets[] = $target; 366 | } 367 | } 368 | } 369 | if (isset($request->commands['mp-syndicate-to'])) { 370 | $syndication_targets = array_unique(array_merge($syndication_targets, $request->commands['mp-syndicate-to'])); 371 | } 372 | if (! empty($syndication_targets)) { 373 | # ensure we don't have duplicate syndication targets 374 | foreach ($syndication_targets as $target) { 375 | if (function_exists("syndicate_$target")) { 376 | $syndicated_url = call_user_func("syndicate_$target", $config['syndication'][$target], $properties, $content, $url); 377 | if (false !== $syndicated_url) { 378 | $syndicated_urls["$target-url"] = $syndicated_url; 379 | } 380 | } 381 | } 382 | if (!empty($syndicated_urls)) { 383 | # convert the array of syndicated URLs into scalar key/value pairs 384 | # if this is an article let's just re-write it, 385 | # with the new properties in the front matter. 386 | # NOTE: we are NOT rebuilding the site at this time. 387 | # I am unsure whether I even want to display these 388 | # links. But it's easy enough to collect them, for now. 389 | if ($properties['posttype'] == 'article') { 390 | foreach ($syndicated_urls as $k => $v) { 391 | $properties[$k] = $v; 392 | } 393 | $file_contents = build_post($properties, $content); 394 | write_file($filename, $file_contents, true); 395 | } else { 396 | # this is not an article, so we should be able to simply 397 | # append the syndicated URL to the YAML data file 398 | foreach ($syndicated_urls as $k => $v) { 399 | file_put_contents($yaml_path, " $k: $v\n", FILE_APPEND); 400 | } 401 | } 402 | } 403 | } 404 | # send a 201 response, with the URL of this item. 405 | quit(201, null, null, $url); 406 | } 407 | 408 | ?> 409 | -------------------------------------------------------------------------------- /inc/debug.php: -------------------------------------------------------------------------------- 1 | 6000000 ) { 56 | quit(413, 'too_large', 'The file is too large.'); 57 | } 58 | # now make sure it's an image. We only deal with JPG, GIF, PNG right now 59 | $finfo = new finfo(FILEINFO_MIME_TYPE); 60 | if (false === $ext = array_search($finfo->file($file['tmp_name']), 61 | array( 62 | 'jpg' => 'image/jpeg', 63 | 'jpeg' => 'image/jpeg', 64 | 'png' => 'image/png', 65 | 'gif' => 'image/gif', 66 | ), true) ) { 67 | quit(415, 'invalid_file', 'Invalid file type was uploaded.'); 68 | } 69 | 70 | $ext = strtolower(pathinfo(basename($file['name']), PATHINFO_EXTENSION)); 71 | if ( $ext == 'jpg' ) { 72 | # normalize JPEG extension, so we can invoke GD functions easier 73 | $ext = 'jpeg'; 74 | } 75 | # define our own name for this file. 76 | # and replace spaces with dashes, for sanity and safety 77 | $orig = str_replace(' ', '-', explode('.', $file['name'])[0]); 78 | $date = new DateTime(); 79 | $filename = $orig . '-' . $date->format('u') . "-$max_width.$ext"; 80 | # extra caution to ensure the file doesn't already exist 81 | if ( file_exists("$target_dir$filename")) { 82 | quit(409, 'file_exists', 'A filename conflict has occurred on the server.'); 83 | } 84 | 85 | # we got here, so let's copy the file into place. 86 | if (! move_uploaded_file($file["tmp_name"], "$target_dir$filename")) { 87 | quit(403, 'file_error', 'Unable to save the uploaded file'); 88 | } 89 | 90 | // check the image and resize if necessary 91 | $details = getimagesize("$target_dir$filename"); 92 | if ( $details === false ) { 93 | quit(415, 'invalid_file', 'Invalid file type was uploaded.'); 94 | } 95 | if ( $details[0] > $max_width ) { 96 | resize_image("$target_dir$filename", $max_width ); 97 | } 98 | 99 | # let's make a thumbnail, too. 100 | $thumbnail = str_replace("-$max_width.$ext", "-200.$ext", $filename); 101 | copy("$target_dir$filename", "$target_dir$thumbnail"); 102 | resize_image("$target_dir$thumbnail", 200 ); 103 | 104 | return $filename; 105 | } 106 | ?> 107 | -------------------------------------------------------------------------------- /inc/twitter.php: -------------------------------------------------------------------------------- 1 | user->screen_name . '/status/' . $tweet->id_str; 16 | } 17 | 18 | # Tweets are fully quotable in most contexts, so these are 19 | # all just wrappers around a single function that handles these cases. 20 | function in_reply_to_twitter_com($properties, $content) { 21 | return twitter_source('in-reply-to', $properties, $content); 22 | } 23 | function repost_of_twitter_com($properties, $content) { 24 | return twitter_source('repost-of', $properties, $content); 25 | } 26 | function bookmark_of_twitter_com($properties, $content) { 27 | return twitter_source('bookmark-of', $properties, $content); 28 | } 29 | function in_reply_to_m_twitter_com($properties, $content) { 30 | return twitter_source('in-reply-to', $properties, $content); 31 | } 32 | function in_reply_to_mobile_twitter_com($properties, $content) { 33 | return twitter_source('in-reply-to', $properties, $content); 34 | } 35 | function repost_of_m_twitter_com($properties, $content) { 36 | return twitter_source('repost-of', $properties, $content); 37 | } 38 | function repost_of_mobile_twitter_com($properties, $content) { 39 | return twitter_source('repost-of', $properties, $content); 40 | } 41 | function bookmark_of_m_twitter_com($properties, $content) { 42 | return twitter_source('bookmark-of', $properties, $content); 43 | } 44 | function bookmark_of_mobile_twitter_com($properties, $content) { 45 | return twitter_source('bookmark-of', $properties, $content); 46 | } 47 | 48 | # replies and reposts have very similar markup, so this builds it. 49 | function twitter_source( $type, $properties, $content) { 50 | global $config; 51 | if (!isset($config['syndication']['twitter'])) { 52 | return [$properties, $content]; 53 | } 54 | 55 | $tweet = get_tweet($config['syndication']['twitter'], $properties[$type]); 56 | if ( false !== $tweet ) { 57 | $properties["$type-name"] = $tweet->user->name; 58 | $properties["$type-content"] = parse_tweet($tweet); 59 | } else { 60 | $properties["$type-name"] = "a Twitter user"; 61 | } 62 | return [$properties, $content]; 63 | } 64 | 65 | function syndicate_twitter($config, $properties, $content, $url) { 66 | # build our Twitter object 67 | $t = twitter_init($config['key'], $config['secret'], $config['token'], $config['token_secret']); 68 | 69 | # a pure retweet has no original content; just the source tweet. 70 | if (isset($properties['repost-of']) && empty($content)) { 71 | # we can only retweet things that originated at Twitter, so 72 | # confirm that the URL we're reposting is a Twitter URL. 73 | $host = parse_url($properties['repost-of'], PHP_URL_HOST); 74 | if (! in_array($host, ['mobile.twitter.com','twitter.com','www.twitter.com','twtr.io'])) { 75 | return false; 76 | } 77 | $id = get_tweet_id($properties['repost-of']); 78 | $tweet = $t->post("statuses/retweet/$id"); 79 | if ($t->getLastHttpCode() != 200) { 80 | return false; 81 | } 82 | return build_tweet_url($tweet); 83 | } 84 | 85 | # Not a pure retweet. May be a retweet with comment. May be a reply. 86 | # May have media. Build up what's needed. 87 | $params = [] ; 88 | 89 | if (isset($properties['in-reply-to'])) { 90 | $host = parse_url($properties['in-reply-to'], PHP_URL_HOST); 91 | if (! in_array($host, ['mobile.twitter.com','twitter.com','www.twitter.com','twtr.io'])) { 92 | # we can't currently syndicate replies to non-Twitter sources. 93 | return false; 94 | } 95 | # replies need an ID to which they are replying. 96 | $params['in_reply_to_status_id'] = get_tweet_id($properties['in-reply-to']); 97 | $params['auto_populate_reply_metadata'] = true; 98 | } 99 | 100 | if (isset($properties['photo']) && !empty($properties['photo'])) { 101 | # if this post has photos, upload them to Twitter, and obtain 102 | # the relevant media ID, for inclusion with the tweet. 103 | $photos = []; 104 | foreach($properties['photo'] as $p) { 105 | $upload = $t->upload('media/upload', ['media' => $p]); 106 | if ($t->getLastHttpCode() == 200) { 107 | $photos[] = $upload->media_id_string; 108 | } 109 | } 110 | if (!empty($photos)) { 111 | $params['media_ids'] = implode(',', $photos); 112 | } 113 | } 114 | 115 | if (isset($properties['title'])) { 116 | # we're announcing a new article. The user should have some prefix 117 | # defined in the config to tweet in front of the title of the post, 118 | # followed by the URL of the post. 119 | $params['status'] = $config['prefix'] . $properties['title'] . "\n" . $url; 120 | } else { 121 | # no title means this is a "note". So just post the content directly. 122 | $params['status'] = $content; 123 | # if this is a retweet with comment, append the URL of the tweet 124 | # https://twittercommunity.com/t/method-to-retweet-with-comment/35330/21 125 | if (isset($properties['repost-of'])) { 126 | $params['status'] .= ' ' . $properties['repost-of']; 127 | } 128 | } 129 | $tweet = $t->post('statuses/update', $params); 130 | if (! $t->getLastHttpCode() == 200) { 131 | return false; 132 | } 133 | return build_tweet_url($tweet); // in case we want to do something with this. 134 | } 135 | 136 | function get_tweet($config, $url) { 137 | $t = twitter_init($config['key'], $config['secret'], $config['token'], $config['token_secret']); 138 | $id = get_tweet_id($url); 139 | $tweet = $t->get("statuses/show", ['id' => $id, 'tweet_mode' => 'extended']); 140 | if (! $t->getLastHttpCode() == 200) { 141 | // error :( 142 | return false; 143 | } 144 | return $tweet; 145 | } 146 | 147 | # this takes a tweet and replaces all the t.co links with real ones, 148 | # as well as link to user names and display media. 149 | # it will recursively display one quoted tweet in the same way. 150 | function parse_tweet ($tweet, $recurse = 0) { 151 | $text = $tweet->full_text; 152 | 153 | if (! empty($tweet->entities->urls)) { 154 | foreach ($tweet->entities->urls as $url) { 155 | $text = preg_replace('#' . preg_quote($url->url) . '#', $url->expanded_url, $text); 156 | } 157 | } 158 | 159 | if (! empty($tweet->entities->user_mentions)) { 160 | foreach ($tweet->entities->user_mentions as $user) { 161 | $text = preg_replace('#@' . preg_quote($user->screen_name) . '#', '@' . $user->screen_name . '', $text); 162 | } 163 | } 164 | 165 | if (! empty($tweet->entities->media)) { 166 | foreach ($tweet->entities->media as $media) { 167 | if ($media->type == 'photo') { 168 | $text = preg_replace('#' . preg_quote($media->url) . '#', '', $text); 169 | } 170 | } 171 | } 172 | 173 | if ($tweet->is_quote_status == 1 && $recurse == 0) { 174 | $quote = parse_tweet($tweet->quoted_status, 1); 175 | $quote = '

' . parse_tweet($tweet->quoted_status, $recurse) . '

' . $tweet->quoted_status->user->name . '
'; 176 | $quote_url = 'https://twitter.com/' . $tweet->quoted_status->user->screen_name . '/status/' . $tweet->quoted_status->id_str; 177 | $text = str_replace($quote_url, '', $text);; 178 | $text = $quote . $text; 179 | } 180 | return $text; 181 | } 182 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | action): 96 | case 'delete': 97 | delete($request); 98 | break; 99 | case 'undelete': 100 | undelete($request); 101 | break; 102 | case 'update': 103 | update($request, $photo_urls); 104 | break; 105 | default: 106 | create($request, $photo_urls); 107 | break; 108 | endswitch; 109 | } else { 110 | # something other than GET or POST? Unsupported. 111 | quit(400, 'invalid_request', 'HTTP method unsupported'); 112 | } 113 | ?> 114 | --------------------------------------------------------------------------------