├── .travis.yml ├── .eslintrc.json ├── LICENSE ├── package.json ├── config └── config.js ├── app.json ├── README.md └── app.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | - '9' 5 | - '8' 6 | - '7' 7 | - '6' 8 | cache: 9 | directories: 10 | - "node_modules" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "semistandard", 3 | "root": true, 4 | "parserOptions": { 5 | "ecmaVersion": 6 6 | }, 7 | "rules": { 8 | "no-var": 2, 9 | "indent": 0, 10 | "camelcase": 0 11 | } 12 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Amit Gawande 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blotpub", 3 | "version": "0.1.0", 4 | "description": "A micropub endpoint that accepts requests, formats them into Blot posts and pushes them to a configured Dropbox folder.", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "test": "installed-check -e && eslint .", 9 | "prepush": "npm test" 10 | }, 11 | "keywords": [ 12 | "micropub", 13 | "indieweb" 14 | ], 15 | "author": "Amit Gawande ", 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/am1t/blotpub" 19 | }, 20 | "license": "MIT", 21 | "engines": { 22 | "node": "^6.14.0 || ^8.10.0 || >=9.10.0" 23 | }, 24 | "dependencies": { 25 | "cheerio": "^1.0.0-rc.12", 26 | "dropbox": "^4.0.9", 27 | "express": "^4.18.2", 28 | "isomorphic-fetch": "^2.2.1", 29 | "lodash.kebabcase": "^4.1.1", 30 | "micropub-express": "git://github.com/am1t/node-micropub-express.git#394889e82514f3cfc0138bd6ec9ffd97ac6e34ce", 31 | "remove-markdown": "^0.3.0", 32 | "request": "^2.87.0", 33 | "twitter": "^1.7.1" 34 | }, 35 | "devDependencies": { 36 | "ajv": "^6.12.3", 37 | "eslint": "^5.15.3", 38 | "eslint-config-semistandard": "^12.0.1", 39 | "eslint-config-standard": "^11.0.0", 40 | "eslint-plugin-import": "^2.14.0", 41 | "eslint-plugin-node": "^7.0.1", 42 | "eslint-plugin-promise": "^4.0.0", 43 | "eslint-plugin-standard": "^3.1.0", 44 | "installed-check": "^2.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const env = process.env; 4 | 5 | let default_path = '/Apps/Blot/'; 6 | 7 | const validatePath = function (path) { 8 | path = path.startsWith('/') ? path : '/' + path; 9 | path += path.endsWith('/') ? '' : '/'; 10 | 11 | return path; 12 | }; 13 | 14 | const config = { 15 | token: env['AUTH_TOKEN_ENDPOINT'] 16 | ? { 17 | endpoint: env['AUTH_TOKEN_ENDPOINT'], 18 | me: env['SITE_URL'] 19 | } 20 | : {}, 21 | dropbox_token: env['DROPBOX_TOKEN'], 22 | default_tag: env['DEFAULT_TAG'], 23 | post_path: validatePath(env['POST_PATH'] = env['POST_PATH'] !== undefined ? env['POST_PATH'] : default_path), 24 | micro_post_path: validatePath(env['MICRO_POST_PATH'] !== undefined ? env['MICRO_POST_PATH'] : default_path), 25 | photo_path: validatePath(env['PHOTO_PATH'] !== undefined ? env['PHOTO_PATH'] : default_path + 'img/'), 26 | photo_uri: env['PHOTO_RELATIVE_URI'] !== undefined ? env['PHOTO_RELATIVE_URI'] : 'img', 27 | site_url: env['SITE_URL'], 28 | set_date: JSON.parse(env['SET_DATE'] ? env['SET_DATE'] : false), 29 | syndicate_to: env['SYNDICATE_TO'] !== undefined ? [].concat(JSON.parse(env['SYNDICATE_TO'])) : [], 30 | mastodon_instance: env['MASTODON_INSTANCE'], 31 | mastodon_token: env['MASTODON_TOKEN'], 32 | twitter_instance: env['TWITTER_INSTANCE'], 33 | twitter_api_key: env['TWITTER_API_KEY'], 34 | twitter_api_secret: env['TWITTER_API_SECRET'], 35 | twitter_access: env['TWITTER_ACCESS_TOKEN'], 36 | twitter_access_secret: env['TWITTER_ACCESS_TOKEN_SECRET'], 37 | media_endpoint: env['MEDIA_ENDPOINT'], 38 | telegraph_webmention_ep: 'https://telegraph.p3k.io/webmention', 39 | mb_webmention_ep: 'https://micro.blog/webmention', 40 | telegraph_token: env['TELEGRAPH_TOKEN'] 41 | }; 42 | 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blotpub", 3 | "description": "An endpoint that accepts Micropub requests, creates a simple Blot posts and saves them to a configured Dropbox folder.", 4 | "keywords": [ 5 | "indieweb", 6 | "micropub", 7 | "api", 8 | "blot", 9 | "dropbox" 10 | ], 11 | "repository": "https://github.com/am1t/blotpub", 12 | "env": { 13 | "AUTH_TOKEN_ENDPOINT": { 14 | "description": "URL to verify Micropub token. Refer https://indieweb.org/token-endpoint", 15 | "required": true, 16 | "value": "https://tokens.indieauth.com/token" 17 | }, 18 | "SITE_URL": { 19 | "description": "URL for your site. Example: https://johndoe.example", 20 | "required": true 21 | }, 22 | 23 | "DROPBOX_TOKEN": { 24 | "description": "Dropbox access token to grant access to Dropbox folder. Example: 12345abcde67890fghij09876klmno54321pqrst", 25 | "required": true 26 | }, 27 | "POST_PATH": { 28 | "description": "Dropbox path where posts are to be stored", 29 | "required": false, 30 | "value": "/Apps/Blot/" 31 | }, 32 | "MICRO_POST_PATH": { 33 | "description": "Dropbox path where micro posts are to be stored", 34 | "required": false, 35 | "value": "/Apps/Blot/" 36 | }, 37 | "PHOTO_PATH": { 38 | "description": "Dropbox path where images are to be stored", 39 | "required": false, 40 | "value": "/Apps/Blot/img/" 41 | }, 42 | "PHOTO_RELATIVE_URI": { 43 | "description": "Relative public URI to uploaded images (ignoring Site URL). Default to blank", 44 | "required": false, 45 | "value": "img" 46 | }, 47 | "SET_DATE": { 48 | "description": "Flag to enable post creation date to be set explicitly in metadata", 49 | "required": false 50 | }, 51 | "TZ": { 52 | "description": "Overide default timezone for dates to the preferred one", 53 | "required": false 54 | }, 55 | "DEFAULT_TAG": { 56 | "description": "Define default tags for posts with no tags", 57 | "required": false 58 | }, 59 | "SYNDICATE_TO": { 60 | "description": "Syndication target(s). Provided as a JSON array.", 61 | "required": false 62 | }, 63 | "MASTODON_INSTANCE": { 64 | "description": "Mastodon instance where posts need to be syndicated", 65 | "required": false 66 | }, 67 | "MASTODON_TOKEN": { 68 | "description": "Access Token for the Mastodon", 69 | "required": false 70 | }, 71 | "MEDIA_ENDPOINT": { 72 | "description": "Media Endpoint to be used", 73 | "required": false 74 | }, 75 | "TWITTER_INSTANCE": { 76 | "description": "Twitter url with user id", 77 | "required": false 78 | }, 79 | "TWITTER_API_KEY": { 80 | "description": "Twitter Developer's Consumer API Key", 81 | "required": false 82 | }, 83 | "TWITTER_API_SECRET": { 84 | "description": "Twitter Developer's Consumer API Secret", 85 | "required": false 86 | }, 87 | "TWITTER_ACCESS_TOKEN": { 88 | "description": "Twitter Developer's Access Token", 89 | "required": false 90 | }, 91 | "TWITTER_ACCESS_TOKEN_SECRET": { 92 | "description": "Twitter Developer's Access Token Secret", 93 | "required": false 94 | }, 95 | "TELEGRAPH_TOKEN": { 96 | "description": "Access Token for the Telegraph API", 97 | "required": false 98 | } 99 | }, 100 | "buildpacks": [ 101 | { 102 | "url": "heroku/nodejs" 103 | } 104 | ] 105 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micropub for [Blot](https://blot.im), with Dropbox 2 | [![Build Status](https://travis-ci.org/am1t/blotpub.svg?branch=master)](https://travis-ci.org/am1t/blotpub) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fam1t%2Fblotpub.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fam1t%2Fblotpub?ref=badge_shield) 3 | 4 | An endpoint that accepts [Micropub](http://micropub.net/) requests, creates a simple Blot posts and saves them to a configured Dropbox folder. This enables updating a Blot blog through a [Micropub client](https://indieweb.org/Micropub/Clients). 5 | 6 | Currently, the endpoint supports the following. 7 | 8 | * Creation of posts with titles ([articles](https://indieweb.org/article)) and without titles ([notes](https://indieweb.org/note)) 9 | * Metadata creation for tags, slugs and published date 10 | * Support for [like](https://indieweb.org/like) and [reply](https://indieweb.org/reply) post types. Added as metadata `like-of` and `in-reply-to` 11 | * Uploading of image files as `multipart` data. Added as metadata `photo` 12 | * Support for syndicating posts to Mastodon. Added as metadata `mastodon-link` 13 | * Support for syndicating posts to Twitter. Added as metadata `twitter-link` 14 | * In-built media endpoint available at `/micropub/media` 15 | * Support for updating the posts 16 | 17 | A step-by-step setup guide is available at [the introduction blog post](https://blog.amitgawande.com/micropub-endpoint-for-blot). [Full implementation report](https://micropub.rocks/implementation-reports/servers/265/WkpcEN4FhqpE4HN6La7E) is available on [micropub.rocks](https://micropub.rocks/) 18 | 19 | ## TODO 20 | * [x] Add support for media endpoint 21 | * ~~[ ] Implement repost, bookmark post types~~ 22 | * [x] Add support for updating ~~and deleting the posts~~ 23 | 24 | ## Requirements 25 | Requires at least Node.js 6.0.0. 26 | 27 | ## Installation 28 | This is a self-hosteable Micropub endpoint. Install it as a normal Node.js application. Add the required [configuration](#configuration) values via environment variables or similar mechanism. 29 | 30 | You can also deploy directly to Heroku. 31 | 32 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/am1t/blotpub) 33 | 34 | **Note**: This is an alpha release and there may be some edge cases that aren't handled. If you find one, please [report an issue](https://github.com/am1t/blotpub/issues/new). 35 | 36 | ## Endpoint discovery 37 | Once you have deployed the application, your Micropub endpoint can be found at `/micropub` e.g. `https://deployed-blotpub-app.com/micropub`. Note that the endpoint url is different from your website url. It would be the url for the blotpub application that your installed in the 1st step. For Heroku deployment, it would be something like `https://*****. herokuapp.com/micropub` (exact url will be available at Heroku dashboard). 38 | 39 | To enable automatic discovery for your [Micropub endpoint](https://indieweb.org/micropub#Endpoint_Discovery) and [token endpoint](https://indieweb.org/obtaining-an-access-token#Discovery), you will need to add the following values to your Blot site's `` - regularly in the `head.html` file in your theme. 40 | 41 | ``` 42 | 43 | 44 | ``` 45 | 46 | ## Configuration 47 | ### Required values 48 | The following variables are required to enable a Micropub client to push content to your GitHub repository. 49 | 50 | Variable | Description 51 | -------- | ----------- 52 | `AUTH_TOKEN_ENDPOINT` | URL to verify Micropub token. Example: `https://tokens.indieauth.com/token` 53 | `SITE_URL` | URL for your site. Example: `https://johndoe.example` 54 | `DROPBOX_TOKEN` | [Dropbox access token](https://blogs.dropbox.com/developers/2014/05/generate-an-access-token-for-your-own-account/) to grant access to your Dropbox folder. "Permission type" in Dropbox should be "Full Dropbox". Example: `12345abcde67890fghij09876klmno54321pqrst` 55 | `POST_PATH` | (Optional) Dropbox `path` where posts are to be stored. Defaults to `/Apps/Blot/` 56 | `PHOTO_PATH` | (Optional) Dropbox path where images are to be stored. Defaults to `/Apps/Blot/img/` 57 | `PHOTO_RELATIVE_URI` | (Optional) Relative public URI to uploaded images (ignoring Site URL). Defaults to `img` in accordance with `PHOTO_PATH` 58 | `MICRO_POST_PATH` | (Optional) Dropbox `path` where micro posts are to be stored. Defaults to `/Apps/Blot/` 59 | `SET_DATE` | (Optional) A `boolean` flag which if set to `true`, date of the post creation is explicitly added to post metadata 60 | `TZ` | (Optional - only if `SET_DATE` set) By default, post creation date would be in `UTC`. This can be overridden by setting this to the preferred timezone using the [TZ Database Timezone format](http://en.wikipedia.org/wiki/List_of_tz_database_time_zones) 61 | `DEFAULT_TAG` | (Optional) If this property is set and no category is provided, value would be set as the tag 62 | `SYNDICATE_TO` | (Optional) Syndication target(s) provided as a JSON array. E.g. as defined at [spec](https://www.w3.org/TR/micropub/#syndication-targets): [{"uid":"https://social.example/johndoe/","name":"@johndoe on Example Social Network"}] 63 | `MASTODON_INSTANCE` | (Optional) Mastodon instance where posts need to be syndicated 64 | `MASTODON_TOKEN` | (Optional) Access Token for Mastodon 65 | `MEDIA_ENDPOINT` | (Optional) Media Endpoint to be used. Can also be configured to in-built endpoint available at `/micropub/media` 66 | `TWITTER_INSTANCE` | (Optional) Twitter url with user id (e.g. https://twitter.com/johndoe/) 67 | `TWITTER_API_KEY` | (Optional) Twitter Developer's Consumer API Key 68 | `TWITTER_API_SECRET` | (Optional) Twitter Developer's Consumer API Secret 69 | `TWITTER_ACCESS_TOKEN` | (Optional) Twitter Developer's Access Token 70 | `TWITTER_ACCESS_TOKEN_SECRET` | (Optional) Twitter Developer's Access Token Secret 71 | `TELEGRAPH_TOKEN` | (Optional) Access Token for the Telegraph API. If set, webmentions would be sent for reply and like post types 72 | 73 | ## Modules used 74 | * [micropub-express](https://github.com/voxpelli/node-micropub-express) – an [Express](http://expressjs.com/) Micropub endpoint that accepts and verifies Micropub requests and calls a callback with a parsed `micropubDocument`. 75 | 76 | ## Releases 77 | Version | Date | Notes 78 | -------:|:----:|:----- 79 | 0.6 | 2019-03-24 | Added support for syndicating posts to Twitter 80 | 0.5.1 | 2019-01-05 | Fixes for Issue [#3](https://github.com/am1t/blotpub/issues/3) + few minor changes 81 | 0.5 | 2018-08-21 | Introduced an in-built Media endpoint 82 | 0.4 | 2018-08-19 | Added support for syndicating posts to Mastodon 83 | 0.3 | 2018-08-15 | Added support for photo uploads multipart 84 | 0.2 | 2018-08-08 | Added support for like/reply post types 85 | 0.1 | 2018-08-05 | Initial release with support for notes and articles 86 | 87 | 88 | ## License 89 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fam1t%2Fblotpub.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fam1t%2Fblotpub?ref=badge_large) 90 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const micropub = require('micropub-express'); 5 | const request = require('request'); 6 | 7 | require('isomorphic-fetch'); 8 | const Dropbox = require('dropbox').Dropbox; 9 | const kebabCase = require('lodash.kebabcase'); 10 | const cheerio = require('cheerio'); 11 | const removeMd = require('remove-markdown'); 12 | const Twitter = require('twitter'); 13 | const config = require('./config/config'); 14 | 15 | const app = express(); 16 | 17 | app.disable('x-powered-by'); 18 | 19 | let dbx = new Dropbox({ accessToken: config.dropbox_token }); 20 | let post_type = ''; 21 | let trgt_url = ''; 22 | 23 | const isEmpty = function (value) { 24 | if (typeof value === 'undefined' && !value) { return true; } 25 | if (Array.isArray(value) && value.length === 0) { return true; } 26 | if (typeof value === 'object' && Object.keys(value).length === 0) { return true; } 27 | return false; 28 | }; 29 | 30 | const getFileName = function (doc) { 31 | if (doc.mp && typeof doc.mp.slug !== 'undefined' && doc.mp.slug) { 32 | return '' + doc.mp.slug; 33 | } else if (typeof doc.properties['mp-slug'] !== 'undefined' && doc.properties['mp-slug']) { 34 | return '' + doc.properties['mp-slug']; 35 | } else { 36 | if (typeof doc.properties.name !== 'undefined' && doc.properties.name && doc.properties.name[0] !== '') { 37 | return kebabCase(doc.properties.name[0].trim()); 38 | } else { 39 | return '' + Date.now(); 40 | } 41 | } 42 | }; 43 | 44 | const getFilePath = function (doc) { 45 | if (doc.properties.name !== undefined && doc.properties.name[0] !== '') { 46 | return Promise.resolve(config.post_path); 47 | } else { 48 | return Promise.resolve(config.micro_post_path); 49 | } 50 | }; 51 | 52 | const getMetadata = function (doc) { 53 | let metadata = '' + 'title : ' + (doc.properties.name ? doc.properties.name.join('') : '') + '\n'; 54 | 55 | if (typeof config.set_date !== 'undefined' && config.set_date) { 56 | if (doc.properties.published) { 57 | metadata += 'date : ' + doc.properties.published[0] + '\n'; 58 | } else { 59 | let today = new Date(); 60 | metadata += 'date : ' + today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate() + 61 | ' ' + today.getHours() + ':' + today.getMinutes() + '\n'; 62 | } 63 | } 64 | 65 | if (doc.properties.category) { 66 | metadata += 'tags : ' + doc.properties.category.join(', ') + '\n'; 67 | } else if (typeof config.default_tag !== 'undefined') { 68 | metadata += 'tags : ' + config.default_tag + '\n'; 69 | } 70 | 71 | if (doc.properties.rsvp) { 72 | if (Array.isArray(doc.properties.rsvp)) { 73 | metadata += 'rsvp : ' + doc.properties.rsvp[0] + '\n'; 74 | } 75 | } 76 | 77 | if (doc.properties.photo) { 78 | if (Array.isArray(doc.properties.photo)) { 79 | if (doc.properties.photo[0].value) { 80 | metadata += 'photo : ' + doc.properties.photo[0].value + '\n'; 81 | metadata += 'photo-alt : ' + doc.properties.photo[0].alt + '\n'; 82 | } else { 83 | metadata += 'photo : ' + doc.properties.photo.join(', ') + '\n'; 84 | } 85 | } else { 86 | metadata += 'photo : ' + doc.properties.photo + '\n'; 87 | } 88 | } 89 | 90 | if (doc.properties['in-reply-to'] && doc.properties['in-reply-to'][0] !== '') { 91 | metadata += 'in-reply-to : ' + doc.properties['in-reply-to'][0] + '\n'; 92 | metadata += 'is-social : yes\n'; 93 | } else if (doc.properties['like-of'] && doc.properties['like-of'][0] !== '') { 94 | metadata += 'like-of : ' + doc.properties['like-of'][0] + '\n'; 95 | metadata += 'is-social : yes\n'; 96 | } 97 | 98 | return Promise.resolve(metadata.replace(/\n$/, '')); 99 | }; 100 | 101 | const getTitle = function (doc) { 102 | let url = ''; 103 | let titlePre = ''; 104 | if (doc.properties['in-reply-to'] && doc.properties['in-reply-to'][0] !== '') { 105 | url = doc.properties['in-reply-to'][0]; 106 | titlePre = 'in-reply-to-title'; 107 | post_type = 'in-reply-to'; 108 | trgt_url = url; 109 | } else if (doc.properties['like-of'] && doc.properties['like-of'][0] !== '') { 110 | url = doc.properties['like-of'][0]; 111 | titlePre = 'like-of-title'; 112 | post_type = 'like-of'; 113 | trgt_url = url; 114 | } else { 115 | return Promise.resolve(''); 116 | } 117 | 118 | return new Promise((resolve, reject) => { 119 | request(url, function (error, response, body) { 120 | if (!error && response.statusCode === 200) { 121 | let $ = cheerio.load(body); 122 | let title = $('head > title').text().trim(); 123 | resolve(titlePre + ' : ' + title); 124 | } else { 125 | console.log('Failed to load the title for ', url); 126 | resolve(titlePre + ' : a post'); 127 | } 128 | }); 129 | }); 130 | }; 131 | 132 | const handleFiles = function (doc) { 133 | if (isEmpty(doc.files) || (isEmpty(doc.files.photo) && isEmpty(doc.files.file))) { 134 | console.log('No files found to be uploaded'); 135 | return Promise.resolve(''); 136 | } 137 | let files = isEmpty(doc.files.photo) ? doc.files.file : doc.files.photo; 138 | return Promise.all( 139 | (files || []).map(file => { 140 | let photoFileName = file.filename; 141 | if (photoFileName.indexOf('image.') !== -1) { 142 | photoFileName = 'img_' + Date.now() + 143 | photoFileName.substr(photoFileName.lastIndexOf('.'), photoFileName.length); 144 | } 145 | let photoName = config.photo_path + photoFileName; 146 | let photoURL = config.site_url + '/' + config.photo_uri + '/' + photoFileName; 147 | return new Promise((resolve, reject) => { 148 | dbx.filesUpload({ path: photoName, contents: file.buffer }) 149 | .then(response => { 150 | if (!response) { 151 | console.log('Failed to upload the photos'); resolve(''); 152 | } else { 153 | console.log('Photo uploaded at ' + response.path_lower); 154 | resolve(photoURL); 155 | } 156 | }) 157 | .catch(err => { 158 | console.log('Failed to upload the photos\n' + err); 159 | resolve(''); 160 | }); 161 | }); 162 | }) 163 | ).then(result => 'photo: ' + result.filter(value => !!value).join(', ')); 164 | }; 165 | 166 | const getContent = function (doc) { 167 | let content = doc.properties.content; 168 | if (!content) { return Promise.resolve(''); } 169 | 170 | if (Array.isArray(content)) { 171 | return Promise.all(content.map(content => { 172 | if (typeof content !== 'object') { 173 | content = { value: content }; 174 | } 175 | 176 | if (content.html) { 177 | return content.html; 178 | } 179 | 180 | return content.value; 181 | })) 182 | .then(result => result.filter(value => !!value).join('\n') + '\n'); 183 | } 184 | }; 185 | 186 | const syndicate_mast = function (doc, file_name) { 187 | if (isEmpty(doc.properties['mp-syndicate-to']) && 188 | (doc.mp === undefined || isEmpty(doc.mp['syndicate-to']))) { 189 | return Promise.resolve(''); 190 | } 191 | 192 | let syndicate_to = [].concat(!isEmpty(doc.properties['mp-syndicate-to']) 193 | ? doc.properties['mp-syndicate-to'] 194 | : doc.mp['syndicate-to']); 195 | if (syndicate_to.indexOf(config.mastodon_instance) !== -1) { 196 | return getContent(doc).then(content => { 197 | let MASTO_API = config.mastodon_instance + 'api/v1/statuses'; 198 | let post_url = config.site_url + '/' + file_name; 199 | content = removeMd(content); 200 | if (content.length > 512) { 201 | content = content.substr(0, 500 - post_url.length); 202 | content = content.substr(0, Math.min(content.length, content.lastIndexOf(' '))); 203 | content = content + '.. ' + post_url; 204 | } 205 | content = encodeURIComponent(content); 206 | let options = { 207 | url: MASTO_API, 208 | body: 'status=' + content, 209 | headers: {'Authorization': 'Bearer ' + config.mastodon_token} 210 | }; 211 | return new Promise((resolve, reject) => { 212 | request.post(options, function (error, response, body) { 213 | if (error) { 214 | console.log('Failed to syndicate post to mastodon. ' + error); 215 | resolve(''); 216 | } else { 217 | body = JSON.parse(body); 218 | console.log('Post syndicated to Mastodon instance ' + body.url.toString()); 219 | resolve('mastodon-link : ' + body.url.toString()); 220 | } 221 | }); 222 | }); 223 | }); 224 | } else { 225 | return Promise.resolve(''); 226 | } 227 | }; 228 | 229 | const syndicate_twit = function (doc, file_name) { 230 | if (isEmpty(doc.properties['mp-syndicate-to']) && 231 | (doc.mp === undefined || isEmpty(doc.mp['syndicate-to']))) { 232 | return Promise.resolve('\n'); 233 | } 234 | 235 | let syndicate_to = [].concat(!isEmpty(doc.properties['mp-syndicate-to']) 236 | ? doc.properties['mp-syndicate-to'] 237 | : doc.mp['syndicate-to']); 238 | if (syndicate_to.indexOf(config.twitter_instance) !== -1) { 239 | return getContent(doc).then(content => { 240 | let post_url = config.site_url + '/' + file_name; 241 | content = removeMd(content); 242 | if (content.length > 280) { 243 | content = content.substr(0, 275 - post_url.length); 244 | content = content.substr(0, Math.min(content.length, content.lastIndexOf(' '))); 245 | content = content + '.. ' + post_url; 246 | } 247 | 248 | let client = new Twitter({ 249 | consumer_key: config.twitter_api_key, 250 | consumer_secret: config.twitter_api_secret, 251 | access_token_key: config.twitter_access, 252 | access_token_secret: config.twitter_access_secret 253 | }); 254 | return new Promise((resolve, reject) => { 255 | client.post('statuses/update', {status: content}, function (error, tweet, response) { 256 | if (error) { 257 | console.log('Failed to syndicate post to twitter. ' + error); 258 | resolve('\n'); 259 | } else { 260 | let tweet_url = config.twitter_instance + 'status/' + tweet.id_str; 261 | console.log('Post syndicated to Twitter instance ' + tweet_url); 262 | resolve('twitter-link : ' + tweet_url + '\n'); 263 | } 264 | }); 265 | }); 266 | }); 267 | } else { 268 | return Promise.resolve('\n'); 269 | } 270 | }; 271 | 272 | const appendCustom = function (custom_metadata) { 273 | return Promise.resolve().then(() => { 274 | let custom_properties = ''; 275 | custom_metadata.forEach(metadata => { 276 | custom_properties += metadata + '\n'; 277 | }); 278 | return custom_properties.trim(); 279 | }); 280 | }; 281 | 282 | const getFileContent = function (doc, file_name, custom_metadata) { 283 | return Promise.all([ 284 | getMetadata(doc), 285 | getTitle(doc), 286 | handleFiles(doc), 287 | appendCustom(custom_metadata), 288 | syndicate_mast(doc, file_name), 289 | syndicate_twit(doc, file_name), 290 | getContent(doc) 291 | ]) 292 | .then(result => result.filter(value => !!value).join('\n')); 293 | }; 294 | 295 | const getFileNameFromURL = function (post_url) { 296 | let file_name = decodeURIComponent(post_url).split(config.site_url + '/')[1]; 297 | if (file_name.indexOf('/') !== -1) { 298 | file_name = file_name.split('/'); 299 | } 300 | return file_name; 301 | }; 302 | 303 | const buildMicropubDocument = function (file_name) { 304 | let dbx_file_path = config.micro_post_path + file_name + '.md'; 305 | console.log('Trying to fetch file from dropbox - ' + dbx_file_path); 306 | return dbx.filesDownload({path: dbx_file_path}) 307 | .then(res => { 308 | console.log('Fetched the contents of the file from dropbox'); 309 | let file_mp_document = {}; 310 | let file_custom_metadata = []; 311 | let file_content = res.fileBinary + ''; 312 | let file_content_lines = file_content.split(/\r?\n/); 313 | file_mp_document = {'type': ['h-entry']}; 314 | file_mp_document.properties = {}; 315 | file_mp_document.properties.category = []; 316 | file_content_lines.forEach(elm => { 317 | if (elm.indexOf('title :') !== -1 && elm.indexOf('-title') === -1) { 318 | file_mp_document.properties.name = []; 319 | file_mp_document.properties.name.push(elm.split(':')[1].trim()); 320 | } else if (elm.indexOf('date :') !== -1) { 321 | file_mp_document.properties.published = [elm.split('date :')[1].trim()]; 322 | } else if (elm.indexOf('tags :') !== -1) { 323 | elm.split(':')[1].trim().split(',').forEach(tag => { 324 | file_mp_document.properties.category.push(tag.trim()); 325 | }); 326 | } else if (elm.indexOf(' : ') !== -1) { 327 | /* TODO: May need a better way to handle custom metadata as this may 328 | include lines with ' : ' as custom metadata in final file. */ 329 | file_custom_metadata.push(elm); 330 | } 331 | }); 332 | file_mp_document.properties['mp-slug'] = file_name; 333 | file_mp_document.properties.content = [file_content.split(/\r?\n\n/)[1].trim()]; 334 | return { 'mpdoc': file_mp_document, 'custom': file_custom_metadata }; 335 | }) 336 | .catch(function (error) { 337 | console.log('Failed to read file' + error); 338 | return {'mpdoc': {}, 'custom': ''}; 339 | }); 340 | }; 341 | 342 | const timer = ms => new Promise(resolve => setTimeout(resolve, ms)); 343 | 344 | const sendTelegraphWebmention = function (src_url) { 345 | let options = { 346 | url: config.telegraph_webmention_ep, 347 | form: { 348 | source: src_url, 349 | target: trgt_url, 350 | token: config.telegraph_token 351 | } 352 | }; 353 | return new Promise((resolve, reject) => { 354 | request.post(options, function (error, response, body) { 355 | if (error || ([201, 202].indexOf(response.statusCode) === -1)) { 356 | console.log('Failed to send webmention to Telegraph. Status Code - ' + 357 | JSON.stringify(response.body.error)); 358 | resolve(''); 359 | } else { 360 | console.log('Successfully sent webmention to Telegraph'); 361 | resolve('telegraph'); 362 | } 363 | }); 364 | }); 365 | }; 366 | 367 | const sendMBWebmention = function (src_url) { 368 | if (post_type === 'in-reply-to') { 369 | let options = { 370 | url: config.mb_webmention_ep, 371 | body: 'source=' + src_url + 372 | '&target=' + trgt_url 373 | }; 374 | return new Promise((resolve, reject) => { 375 | request.post(options, function (error, response, body) { 376 | if (error || ([201, 202].indexOf(response.statusCode) === -1)) { 377 | console.log('Failed to send webmention to Micro.blog. Status Code - ' + 378 | JSON.stringify(response.body)); 379 | resolve(''); 380 | } else { 381 | console.log('Successfully sent webmention to Micro.blog'); 382 | resolve('micro.blog'); 383 | } 384 | }); 385 | }); 386 | } 387 | }; 388 | 389 | const handleWebmentions = function (src_url) { 390 | if (post_type !== '') { 391 | return Promise.all([ 392 | sendMBWebmention(src_url), 393 | sendTelegraphWebmention(src_url) 394 | ]); 395 | } else { 396 | return Promise.resolve(''); 397 | } 398 | }; 399 | 400 | // Micropub endpoint 401 | app.use('/micropub', micropub({ 402 | 403 | tokenReference: config.token, 404 | queryHandler: (q, req) => { 405 | if (q === 'config') { 406 | const config_res = {}; 407 | if (config.media_endpoint) { config_res['media-endpoint'] = config.media_endpoint; } 408 | if (!isEmpty(config.syndicate_to)) { 409 | config_res['syndicate-to'] = config.syndicate_to; 410 | } 411 | return config_res; 412 | } else if (q === 'syndicate-to') { 413 | return config.syndicate_to ? { 'syndicate-to': config.syndicate_to } : undefined; 414 | } else if (q === 'source') { 415 | console.log('Received request for updating post ' + req.query.url); 416 | let file_name = getFileNameFromURL(req.query.url); 417 | return buildMicropubDocument(file_name) 418 | .then(current_document => { 419 | console.log('Returning the fetched micropub document ' + JSON.stringify(current_document)); 420 | return current_document.mpdoc; 421 | }); 422 | } 423 | }, 424 | handler: function (mp_document, req) { 425 | console.log('Generated Micropub Document \n' + JSON.stringify(mp_document)); 426 | let file_name, dbx_post_mode; 427 | let custom_metadata = []; 428 | return Promise.resolve().then(() => { 429 | if (mp_document.action === undefined) { 430 | file_name = getFileName(mp_document); 431 | dbx_post_mode = 'add'; 432 | return mp_document; 433 | } else { 434 | dbx_post_mode = 'overwrite'; 435 | console.log('Handling the update request'); 436 | file_name = getFileNameFromURL(mp_document.url); 437 | return buildMicropubDocument(file_name).then(file_mp_data => { 438 | let current_document = file_mp_data.mpdoc; 439 | custom_metadata = file_mp_data.custom; 440 | console.log('Updating the micropub document ' + JSON.stringify(current_document)); 441 | let mp_action_type = ['replace', 'add', 'delete']; 442 | mp_action_type.forEach(action_type => { 443 | if (action_type in mp_document) { 444 | let action = Object.keys(mp_document[action_type])[0]; 445 | let updated_property = mp_document[action_type][action]; 446 | console.log('Property to be actioned on ' + JSON.stringify(action)); 447 | if (action_type === 'replace') { 448 | console.log('Handling the replace action type'); 449 | delete current_document.properties[action]; 450 | current_document.properties[action] = updated_property; 451 | console.log('Property ' + JSON.stringify(action) + ' replaced.'); 452 | } else if (action_type === 'add') { 453 | console.log('Handling the add action type'); 454 | if (!current_document.properties[action]) { 455 | current_document.properties[action] = updated_property; 456 | console.log('Property ' + JSON.stringify(action) + ' added.'); 457 | } else { 458 | updated_property.forEach(value => { 459 | current_document.properties[action].push(value); 460 | }); 461 | console.log('Property ' + JSON.stringify(action) + ' updated.'); 462 | } 463 | } else if (action_type === 'delete') { 464 | console.log('Handling the delete action type'); 465 | if (current_document.properties[action] !== undefined) { 466 | current_document.properties[action] = current_document.properties[action] 467 | .filter(value => { 468 | return updated_property.indexOf(value) === -1; 469 | }); 470 | } else { 471 | delete current_document.properties[mp_document[action_type]]; 472 | } 473 | } 474 | } 475 | }); 476 | console.log('Updated the current document ' + JSON.stringify(current_document)); 477 | return current_document; 478 | }) 479 | .catch(function (error) { 480 | console.log('Failed to build the document from file' + error); 481 | return {}; 482 | }); 483 | } 484 | }) 485 | .then(current_document => { 486 | return Promise.all([ 487 | getFilePath(current_document), 488 | getFileContent(current_document, file_name, custom_metadata) 489 | ]); 490 | }) 491 | .then(result => { 492 | let path = result[0]; 493 | let content = result[1]; 494 | // let write_mode 495 | return dbx.filesUpload({ path: path + file_name + '.md', contents: content, mode: dbx_post_mode }) 496 | .then(function (response) { 497 | console.log('Post file uploaded at ' + response.path_lower); 498 | return timer(3000).then(_ => { 499 | return handleWebmentions(config.site_url + '/' + file_name); 500 | }) 501 | .then(res => { 502 | return { url: config.site_url + '/' + file_name }; 503 | }); 504 | }) 505 | .catch(function (err) { 506 | console.log(err); 507 | }); 508 | }); 509 | }, 510 | media_handler: function (data, req) { 511 | console.log('Received request for media handling'); 512 | return Promise.resolve().then(() => { 513 | return handleFiles(data).then(res => { 514 | if (!res) { 515 | console.log('Failed to upload the media.'); 516 | return {}; 517 | } 518 | console.log('Media handled with response ' + res); 519 | let resurl = res.split(/:(.+)/)[1]; 520 | return resurl ? { url: resurl.trim() } : {}; 521 | }); 522 | }); 523 | } 524 | })); 525 | 526 | const port = process.env.PORT || 5000; 527 | app.listen(port, () => { 528 | console.log(`Server Started on port ${port}`); 529 | }); 530 | --------------------------------------------------------------------------------