├── .gitignore ├── src ├── http │ ├── get-webmention-000id │ │ ├── view.js │ │ └── index.js │ ├── get-index │ │ └── index.js │ ├── get-webmention │ │ └── index.js │ └── post-webmention │ │ └── index.js ├── shared │ ├── valid-url.js │ ├── layout.js │ └── status.js └── events │ ├── send │ └── index.js │ ├── verify │ └── index.js │ └── parse │ └── index.js ├── package.json ├── app.arc ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | .arc-env 5 | .env 6 | preferences.arc 7 | sam.json 8 | sam.yaml 9 | .sam.yaml.swp 10 | -------------------------------------------------------------------------------- /src/http/get-webmention-000id/view.js: -------------------------------------------------------------------------------- 1 | function view (locals) { 2 | return ` 3 |
${JSON.stringify(locals, null, 2)}
4 | ` 5 | } 6 | 7 | module.exports = view 8 | -------------------------------------------------------------------------------- /src/shared/valid-url.js: -------------------------------------------------------------------------------- 1 | function isValidURL (string) { 2 | try { 3 | new URL(string) // eslint-disable-line 4 | } catch (_) { 5 | return false 6 | } 7 | return true 8 | } 9 | 10 | module.exports = isValidURL 11 | -------------------------------------------------------------------------------- /src/http/get-index/index.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function http (req) { 2 | return { 3 | statusCode: 200, 4 | headers: { 5 | 'content-type': 'text/plain; charset=utf-8' 6 | }, 7 | body: 'Webmention receiver' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/layout.js: -------------------------------------------------------------------------------- 1 | function layout (content) { 2 | return ` 3 | 4 | 5 | Wembley 6 | 7 | 8 | ${content} 9 | 10 | 11 | ` 12 | } 13 | 14 | module.exports = layout 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wembley", 3 | "version": "0.1.0", 4 | "description": "Webmention receiver", 5 | "scripts": { 6 | "start": "PORT=3335 npx sandbox" 7 | }, 8 | "devDependencies": { 9 | "@architect/architect": "^11.1.0" 10 | }, 11 | "dependencies": { 12 | "@architect/functions": "^3.13.11", 13 | "node-fetch": "^2.6.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app.arc: -------------------------------------------------------------------------------- 1 | @app 2 | wembley 3 | 4 | @aws 5 | region eu-west-2 6 | 7 | @http 8 | get / 9 | get /webmention 10 | get /webmention/:id 11 | post /webmention 12 | 13 | @events 14 | parse 15 | send 16 | verify 17 | 18 | @tables 19 | blocks 20 | domain *String 21 | statuses 22 | id *String 23 | webmentions 24 | id *String 25 | 26 | @indexes 27 | statuses 28 | webmention_id *String 29 | created_at **String 30 | webmentions 31 | target *String 32 | created_at **String 33 | -------------------------------------------------------------------------------- /src/shared/status.js: -------------------------------------------------------------------------------- 1 | const arc = require('@architect/functions') 2 | 3 | async function create (webmentionId, message, type) { 4 | const data = await arc.tables() 5 | const id = Math.random().toString(36).substring(2) 6 | await data.statuses.put({ 7 | id, 8 | webmention_id: webmentionId, 9 | created_at: new Date().toISOString(), 10 | type, 11 | message 12 | }) 13 | } 14 | 15 | async function log (webmentionId, message) { 16 | console.log(`${webmentionId} ${message}`) 17 | await create(webmentionId, message, 'log') 18 | } 19 | 20 | async function error (webmentionId, message) { 21 | console.error(`${webmentionId} ${message}`) 22 | await create(webmentionId, message, 'error') 23 | } 24 | 25 | module.exports = { log, error } 26 | -------------------------------------------------------------------------------- /src/http/get-webmention/index.js: -------------------------------------------------------------------------------- 1 | const arc = require('@architect/functions') 2 | 3 | async function findWebmentionsByTarget (target) { 4 | const data = await arc.tables() 5 | const result = await data.webmentions.query({ 6 | IndexName: 'target-created_at-index', 7 | ScanIndexForward: false, 8 | KeyConditionExpression: 'target = :target', 9 | ExpressionAttributeValues: { 10 | ':target': target 11 | } 12 | }) 13 | return result.Items 14 | } 15 | 16 | async function http (request) { 17 | const { target } = request.query 18 | if (!target) { 19 | return { 20 | status: 400, 21 | json: { message: 'target query parameter is missing' } 22 | } 23 | } 24 | 25 | const webmentions = await findWebmentionsByTarget(target) 26 | if (!webmentions) { 27 | return { 28 | status: 404, 29 | json: { message: 'No webmentions found' } 30 | } 31 | } 32 | 33 | return { 34 | json: { webmentions } 35 | } 36 | } 37 | 38 | exports.handler = arc.http.async(http) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Barry Frost 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 | -------------------------------------------------------------------------------- /src/events/send/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const arc = require('@architect/functions') 3 | const status = require('@architect/shared/status') 4 | const isValidURL = require('@architect/shared/valid-url') 5 | 6 | async function handler (event) { 7 | const { id } = event 8 | 9 | const data = await arc.tables() 10 | const webmention = await data.webmentions.get({ id }) 11 | if (!webmention) { 12 | status.error(id, 'Webmention could not be found') 13 | } 14 | 15 | const url = process.env.WEBHOOK_URL 16 | if (!(url)) { 17 | status.error(id, 'WEBHOOK_URL has not been defined in ENV') 18 | return 19 | } 20 | if (!isValidURL(url)) { 21 | status.error(id, 'WEBHOOK_URL is not a valid URL') 22 | return 23 | } 24 | 25 | const response = await fetch(url, { 26 | method: 'post', 27 | headers: { 'Content-Type': 'application/json' }, 28 | body: JSON.stringify(webmention.post) 29 | }) 30 | 31 | console.log('webhook', JSON.stringify(webmention.post, null, 2)) 32 | 33 | if (response.ok) { 34 | status.log('Webhook post was successful') 35 | } else { 36 | status.error('Webhook post failed') 37 | } 38 | 39 | return response.ok 40 | } 41 | 42 | exports.handler = arc.events.subscribe(handler) 43 | -------------------------------------------------------------------------------- /src/http/get-webmention-000id/index.js: -------------------------------------------------------------------------------- 1 | const arc = require('@architect/functions') 2 | const layout = require('@architect/shared/layout') 3 | const view = require('./view') 4 | 5 | async function findStatusesByWebmention (webmentionId) { 6 | const data = await arc.tables() 7 | const result = await data.statuses.query({ 8 | IndexName: 'webmention_id-created_at-index', 9 | ScanIndexForward: false, 10 | KeyConditionExpression: 'webmention_id = :webmentionId', 11 | ExpressionAttributeValues: { 12 | ':webmentionId': webmentionId 13 | } 14 | }) 15 | return result.Items 16 | } 17 | 18 | async function http (request) { 19 | const data = await arc.tables() 20 | 21 | const { id } = request.params 22 | if (!id) { 23 | return { 24 | status: 400, 25 | json: { message: 'id parameter is missing' } 26 | } 27 | } 28 | 29 | const webmention = await data.webmentions.get({ id }) 30 | if (!webmention) { 31 | return { 32 | status: 404, 33 | json: { message: 'Webmention not found' } 34 | } 35 | } 36 | 37 | const statuses = await findStatusesByWebmention(id) 38 | 39 | if (request.headers.accept.startsWith('application/json')) { 40 | return { 41 | json: { ...webmention, statuses } 42 | } 43 | } 44 | 45 | const content = view({ webmention, statuses }) 46 | return { 47 | html: layout(content) 48 | } 49 | } 50 | 51 | exports.handler = arc.http.async(http) 52 | -------------------------------------------------------------------------------- /src/events/verify/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const arc = require('@architect/functions') 3 | const status = require('@architect/shared/status') 4 | 5 | function validContentTypes (contentType) { 6 | const types = [ 7 | 'text/html', 8 | 'application/json', 9 | 'text/plain' 10 | ] 11 | for (const type of types) { 12 | if (contentType.startsWith(type)) { 13 | return true 14 | } 15 | } 16 | } 17 | 18 | async function handler (event) { 19 | const { id } = event 20 | 21 | const data = await arc.tables() 22 | const webmention = await data.webmentions.get({ id }) 23 | if (!webmention) { 24 | status.error(id, 'Webmention could not be found') 25 | return 26 | } 27 | 28 | // fetch default timeout is 5s and 20 redirects 29 | const response = await fetch(webmention.source, { 30 | method: 'get', 31 | size: 1024 * 1024 // 1mb 32 | }) 33 | if (!response.ok) { 34 | status.error(id, 'Source could not be fetched') 35 | return 36 | } 37 | const contentType = response.headers.get('content-type') 38 | if (!validContentTypes(contentType)) { 39 | status.error(id, 'Source Content-Type was invalid') 40 | return 41 | } 42 | const text = await response.text() 43 | if (!text.match(webmention.target)) { 44 | status.error(id, 'Source does not include a link to target') 45 | return 46 | } 47 | 48 | await arc.events.publish({ 49 | name: 'parse', 50 | payload: { id } 51 | }) 52 | 53 | await status.log(id, 'Source links to target') 54 | } 55 | 56 | exports.handler = arc.events.subscribe(handler) 57 | -------------------------------------------------------------------------------- /src/http/post-webmention/index.js: -------------------------------------------------------------------------------- 1 | const arc = require('@architect/functions') 2 | const status = require('@architect/shared/status') 3 | const isValidURL = require('@architect/shared/valid-url') 4 | 5 | async function findBlock (source) { 6 | const data = await arc.tables() 7 | const domain = new URL(source).host 8 | const block = data.blocks.get({ domain }) 9 | return block 10 | } 11 | 12 | function validate (source, target) { 13 | if (!source) return 'source parameter is missing' 14 | if (!isValidURL(source)) return 'source is not a valid URL' 15 | if (!target) return 'target parameter is missing' 16 | if (!isValidURL(target)) return 'target is not a valid URL' 17 | if (source === target) return 'source and target are the same' 18 | if (findBlock(source)) return 'source is blocked' 19 | } 20 | 21 | async function create (source, target) { 22 | const data = await arc.tables() 23 | // random id for webmention record 24 | const id = Math.random().toString(36).substring(2) 25 | await data.webmentions.put({ 26 | id, 27 | source, 28 | target, 29 | created_at: new Date().toISOString() 30 | }) 31 | return id 32 | } 33 | 34 | async function http (request) { 35 | const { source, target } = request.body 36 | 37 | const message = validate(source, target) 38 | if (message) { 39 | return { 40 | status: 400, 41 | json: { message } 42 | } 43 | } 44 | 45 | const id = await create(source, target) 46 | 47 | await status.log(id, 'Received webmention') 48 | 49 | await arc.events.publish({ 50 | name: 'verify', 51 | payload: { id } 52 | }) 53 | 54 | return { 55 | status: 201, 56 | location: `${process.env.ROOT_URL}webmention/${id}` 57 | } 58 | } 59 | 60 | exports.handler = arc.http.async(http) 61 | -------------------------------------------------------------------------------- /src/events/parse/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const arc = require('@architect/functions') 3 | const status = require('@architect/shared/status') 4 | 5 | function getXRayUrl (url) { 6 | const xRayBaseUrl = 'https://xray.p3k.io/parse?url=' 7 | const safeUrl = encodeURIComponent(url) 8 | return xRayBaseUrl + safeUrl 9 | } 10 | 11 | async function fetchSource (webmention) { 12 | const url = getXRayUrl(webmention.source) 13 | const response = await fetch(url) 14 | if (!response.ok) return 15 | const jf2 = await response.json() 16 | if (!('data' in jf2)) return 17 | return jf2 18 | } 19 | 20 | async function handler (event) { 21 | const { id } = event 22 | 23 | const data = await arc.tables() 24 | const webmention = await data.webmentions.get({ id }) 25 | if (!webmention) { 26 | status.error(id, 'Webmention could not be found') 27 | return 28 | } 29 | 30 | const jf2 = await fetchSource(webmention) 31 | if (!jf2) { 32 | status.error(id, 'Source could not be parsed') 33 | return 34 | } 35 | status.log(id, 'Source was parsed') 36 | 37 | // TODO: upload photo to cloudinary 38 | 39 | const post = { 40 | url: jf2.url, 41 | type: jf2.data.type, 42 | published: jf2.data.published, 43 | author: jf2.data.author 44 | } 45 | 46 | switch (jf2.data['post-type']) { 47 | case 'like': 48 | post['like-of'] = jf2.data['like-of'] 49 | post['wm-property'] = 'like-of' 50 | break 51 | case 'repost': 52 | post['repost-of'] = jf2.data['repost-of'] 53 | post['wm-property'] = 'repost-of' 54 | break 55 | case 'reply': 56 | post['in-reply-to'] = jf2.data['in-reply-to'][0] 57 | post['wm-property'] = 'in-reply-to' 58 | post.content = jf2.data.content.text 59 | if (jf2.data.name) { post.name = jf2.data.name } 60 | break 61 | } 62 | 63 | /* 64 | "post": { 65 | "type": "entry", 66 | "author": { 67 | "name": "Amy Guy", 68 | "photo": "http://webmention.io/avatar/rhiaro.co.uk/829d3f6e7083d7ee8bd7b20363da84d88ce5b4ce094f78fd1b27d8d3dc42560e.png", 69 | "url": "http://rhiaro.co.uk/about#me" 70 | }, 71 | "url": "http://rhiaro.co.uk/2015/11/1446953889", 72 | "published": "2015-11-08T03:38:09+00:00", 73 | "name": "repost of http://aaronparecki.com/notes/2015/11/07/4/indiewebcamp", 74 | "repost-of": "http://aaronparecki.com/notes/2015/11/07/4/indiewebcamp", 75 | "wm-property": "repost-of" 76 | } 77 | */ 78 | 79 | data.webmentions.put({ post, ...webmention }) 80 | 81 | await arc.events.publish({ 82 | name: 'send', 83 | payload: { id } 84 | }) 85 | } 86 | 87 | exports.handler = arc.events.subscribe(handler) 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wembley 2 | 3 | This is a [Webmention](https://webmention.net) receiver to be used as an 4 | endpoint to receive webmentions for your website. 5 | 6 | It's still very much a work-in-progress and not ready for use, but I wanted to 7 | share progress as the spec evolves in the [IndieWeb](https://indieweb.org) 8 | community. 9 | 10 | * Node.js with [Architect framework](https://arc.codes) 11 | * Deploy to AWS using Lambda, API Gateway, DynamoDB, SNS 12 | 13 | ## Outline 14 | 15 | ### Receiving webmentions 16 | 17 | * **POST /webmention** with `source` and `target` form parameters 18 | * Return 400 if `source` and `target` are not valid URLs 19 | * Return 400 if `source` is the same as target 20 | * Return 400 if `target` is not a known domain (`DOMAINS` env var) 21 | * Return 400 if `source`'s domain is found in blocked domain table 22 | * Generate a unique id for the webmention 23 | * Store `id`, `source`, `target` and a timestamp in webmentions table 24 | * Log status "Received webmention" 25 | * Publish verify event with `id` payload 26 | * Return 201 with `Location` header `/webmention/:id` 27 | * Handle **verify** event with `id` payload 28 | * Get the webmention record from the database 29 | * Fetch the source, limiting to 1Mb, 20 redirects and a 5-second timeout 30 | * Log error if `Content-Type` is not HTML, JSON or text 31 | * Log error if `source` content does not include `target` 32 | * Publish parse event with `id` 33 | * Handle **parse** event with `id` payload 34 | * Get the webmention record from the database 35 | * Send source URL to [XRay](https://xray.p3k.io) to parse into JF2 36 | * Log status "Source was parsed" or error if unsuccessful 37 | * Upload author photo to Cloudinary 38 | * Create a JF2 object (format TBD - see 39 | https://github.com/indieweb/webmention-ecosystem/issues/2) 40 | * Update webmention record with JF2 post in table 41 | * Publish send event with `id` payload 42 | * Handle **send** event with `id` payload 43 | * Get the webmention record from the database 44 | * Send POST to config webhook URL with webmention record in JSON body 45 | 46 | ### Listing webmentions 47 | 48 | * **GET /webmention** with `target` query parameter 49 | * Query for webmentions matching `target` 50 | * Return 404 if no webmentions are found 51 | * Return 200 with list of webmentions as HTML (default) or JSON depending on 52 | `Accept` header 53 | 54 | ### Webmention statuses 55 | 56 | * **GET /webmention/:id** 57 | * Query for statuses matching `id` 58 | * Return 404 if no statuses are found 59 | * Return 200 with list of statuses as HTML (default) or JSON depending on 60 | `Accept` header 61 | 62 | ## env 63 | 64 | * `ROOT_URL` e.g. https://wembley.barryfrost.com/ 65 | * `DOMAINS` e.g. barryfrost.com 66 | * `WEBHOOK_URL` e.g. https://api.barryfrost.com/webmention 67 | --------------------------------------------------------------------------------