├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── CHANGELOG ├── Dockerfile ├── LICENSE ├── README.md ├── bin ├── client-address.ts ├── create-post ├── deploy ├── post-fixtures.ts └── server.ts ├── config └── index.ts ├── docker-compose.yml ├── etc └── distbin-nginx-subpath │ ├── README.md │ ├── docker-compose.yml │ └── nginx.conf ├── index.js ├── package-lock.json ├── package.json ├── src ├── activitypub.ts ├── activitystreams │ ├── index.ts │ └── types.ts ├── as2context.json ├── distbin-html │ ├── about.ts │ ├── an-activity.ts │ ├── home.ts │ ├── index.ts │ ├── partials.ts │ ├── public.ts │ ├── sanitize.ts │ └── url-rewriter.ts ├── filemap.ts ├── index.ts ├── logger.ts ├── types.ts └── util.ts ├── test ├── activitypub.ts ├── activitystreams │ └── index.ts ├── distbin-html │ └── index.js ├── distbin.ts ├── federation.ts ├── filemap.ts ├── http-utils.ts ├── index.ts ├── ldn.ts ├── types.ts └── util.ts ├── tsconfig.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "standard", 3 | "parser": "typescript-eslint-parser", 4 | "plugins": [ 5 | "standard", 6 | "promise", 7 | "no-only-tests" 8 | ], 9 | "rules": { 10 | "no-undef": 0, 11 | "no-unused-vars": 0, 12 | "no-useless-constructor": 0, 13 | "space-infix-ops": 0, 14 | "no-only-tests/no-only-tests": 2 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bengo-notes 2 | /node_modules 3 | TODO 4 | /dist 5 | /.vs 6 | /.vscode 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | script: 6 | - npm test 7 | - npm run lint 8 | - npm run build 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # vNext 2 | 3 | [] - Refactored homepage with explanatory text on /about page 4 | 5 | # Opportunities 6 | 7 | [] - measureable pageviews 8 | 9 | # 12/29/2016 10 | 11 | Has explanatory homepage. Implements ActivityPub outbox/inbox for federation. Each activity has an html representation that shows inReplyTo threads. Can run with included Dockerfile. Deployed to distbin.com as well as {a,b,c}.distbin.com (see ~/domains/distbin.com/ for docker-compose). 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie-slim 2 | 3 | RUN apt-get update --fix-missing && apt-get -y --no-install-recommends install \ 4 | ca-certificates \ 5 | curl \ 6 | sudo \ 7 | rsync 8 | 9 | # install node.js 10 | RUN curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - 11 | RUN sudo apt-get install -y nodejs 12 | 13 | # clean up after apt-get 14 | RUN rm -rf /var/lib/apt/lists/* && \ 15 | apt-get clean && \ 16 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 17 | 18 | # Change the working directory. 19 | WORKDIR /home/distbin/app 20 | 21 | # Install dependencies. 22 | COPY package.json ./ 23 | COPY package-lock.json ./ 24 | RUN npm install --ignore-scripts 25 | 26 | # Copy project directory. 27 | COPY . ./ 28 | 29 | RUN npm run build 30 | 31 | RUN mkdir -p /distbin-db/activities 32 | RUN mkdir -p /distbin-db/inbox 33 | # distbin will store data as files in this directory 34 | VOLUME /distbin-db 35 | 36 | # read by ./bin/server 37 | ENV DB_DIR=/distbin-db 38 | 39 | ENV PORT=80 40 | EXPOSE 80 41 | 42 | CMD ["npm", "start"] 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # distbin 2 | 3 | Post bin with distributed social features. 4 | 5 | A networked place to store posted web documents. This is meant to allow for distributed social commentary and reaction around these documents using best practices recommended or noted by the W3C Social Web Working Group. 6 | 7 | ## Use Cases 8 | 9 | * Wrote a poem but too embarassed to attach your name? Don't worry. Post it here! 10 | * Want to reply to a thought somewhere on the web, but that place doesn't have a comment system and you don't want to set up a blog or give your information to another organization? Post it here! Your Post can have an in-reply-to relation to another web resource. 11 | * Want to fact-check a selection of text somewhere on the web, but don't have a place to do it? Post it here! Your post can be an annotation of another web resource. 12 | 13 | ## Usage 14 | 15 | ### Quickstart using docker without cloning 16 | 17 | ``` 18 | docker run -p 8000:80 gobengo/distbin 19 | ``` 20 | 21 | `open http://localhost:8000` 22 | 23 | ### Quickstart using docker-compose 24 | 25 | `docker-compose run -p 8000:80 distbin` 26 | 27 | ### Run the server to develop 28 | 29 | `npm run start-dev` 30 | 31 | ### Interacting with a running distbin 32 | 33 | Create a Post `./bin/create-post` 34 | 35 | ## Demo 36 | 37 | * [Official demo](https://distbin.com/) 38 | 39 | 40 | ## Configuration 41 | 42 | Configure distbin with the following environment variables: 43 | 44 | * `DB_DIR` - path to a directory in which distbin should read/write data 45 | * `PORT` - HTTP Port for distbin to listen on 46 | * `EXTERNAL_URL` - The public-facing base URL that distbin is deployed at, e.g. `http://yourdomain.com/distbin/` 47 | * `INTERNAL_URL` - If distbin is running with a network configuration such that it cannot make requests to the `EXTERNAL_URL`, all outgoing requests to the `EXTERNAL_URL` will be replaced with this `INTERNAL_URL`. See [./etc/distbin-nginx-subpath/docker-compose.yml](./etc/distbin-nginx-subpath/docker-compose.yml) for an example. 48 | * `DISTBIN_DELIVER_TO_LOCALHOST` - default: false in production - Whether or not to allow distbin to make requests to `localhost` URLs. This is discouraged in [the security considerations of the ActivityPub spec](https://www.w3.org/TR/activitypub/#security-localhost) -------------------------------------------------------------------------------- /bin/client-address.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | import * as url from "url" 4 | import { objectTargets } from "../src/activitypub" 5 | import { ASJsonLdProfileContentType } from "../src/activitystreams" 6 | import { createLogger } from "../src/logger" 7 | import { debuglog, ensureArray, readableToString, request, sendRequest } from "../src/util" 8 | 9 | const logger = createLogger(__filename) 10 | 11 | if (require.main === module) { 12 | main() 13 | } 14 | 15 | async function main() { 16 | const args = process.argv.slice(2) 17 | const [targetUrl] = args 18 | logger.info("client addressing for url", targetUrl) 19 | const urlResponse = await sendRequest(request(Object.assign( 20 | url.parse(targetUrl), 21 | { 22 | headers: { 23 | accept: ASJsonLdProfileContentType, 24 | }, 25 | }, 26 | ))) 27 | const urlBody = await readableToString(urlResponse) 28 | const fetchedObject = JSON.parse(urlBody) 29 | const targets = objectTargets(fetchedObject, 0, false, (u: string) => u) 30 | logger.info("", { targets }) 31 | } 32 | -------------------------------------------------------------------------------- /bin/create-post: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Create a Post by issuing an HTTP Request via cURL 4 | 5 | # debug 6 | set -e 7 | # verbose 8 | # set -x 9 | 10 | collection_url="$1" 11 | 12 | # TODO: Not a good AS2 Post 13 | request_body=$(cat < 5 | */ 6 | 7 | import * as http from "http" 8 | import * as url from "url" 9 | import { Activity, ASObject } from "../src/types" 10 | import { first, readableToString } from "../src/util" 11 | import { sendRequest } from "../src/util" 12 | 13 | if (require.main === module) { 14 | const [distbinUrl] = process.argv.slice(2) 15 | postManyFixtures(distbinUrl) 16 | .then(() => process.exit()) 17 | .catch((err: Error) => { 18 | /* tslint:disable-next-line:no-console */ 19 | console.error("Uncaught Error", err) 20 | process.exit(1) 21 | }) 22 | } 23 | 24 | // Create a sample activity 25 | function createNoteFixture({ inReplyTo }: {inReplyTo: string}): ASObject { 26 | const fixture: ASObject = { 27 | cc: ["https://www.w3.org/ns/activitystreams#Public"], 28 | content: loremIpsum(), 29 | inReplyTo, 30 | type: "Note", 31 | } 32 | return fixture 33 | } 34 | 35 | // post many fixtures, including replies, to distbinUrl over HTTP 36 | async function postManyFixtures( 37 | distbinUrl: string, 38 | { max= 32, maxDepth= 4, thisDepth= 1, inReplyTo }: { 39 | max?: number, 40 | maxDepth?: number, 41 | thisDepth?: number, 42 | inReplyTo?: string, 43 | }= {}): Promise { 44 | const posted: Activity[] = [] 45 | while (max--) { 46 | // console.log('max', max) 47 | const activityUrl = await postActivity(distbinUrl, createNoteFixture({ inReplyTo })) 48 | /* tslint:disable-next-line:no-console */ 49 | console.log(new Array(thisDepth - 1).join(".") + url) 50 | // posted.push(activityUrl) 51 | // post children 52 | if (maxDepth > 1) { 53 | // we must go deeper 54 | const mnlf = Math.random() * max 55 | // console.log('mnlf', mnlf) 56 | const maxNextLevel = Math.round(mnlf) 57 | max = max - maxNextLevel 58 | // console.log(`posting ${maxNextLevel} at level ${maxDepth-1}, leaving ${max} remaining`) 59 | posted.push.apply(posted, await postManyFixtures(distbinUrl, { 60 | inReplyTo: activityUrl, 61 | max: maxNextLevel, 62 | maxDepth: maxDepth - 1, 63 | thisDepth: thisDepth + 1, 64 | })) 65 | } 66 | } 67 | return posted 68 | } 69 | 70 | // post a single fixture to distbinUrl over HTTP 71 | async function postActivity(distbinUrl: string, activity: ASObject) { 72 | if (!distbinUrl) { 73 | throw new Error("Please provide a distbinUrl argument") 74 | } 75 | const postRequest = http.request(Object.assign(url.parse(distbinUrl), { 76 | method: "post", 77 | path: "/activitypub/outbox", 78 | })) 79 | postRequest.write(JSON.stringify(activity, null, 2)) 80 | const postResponse = await sendRequest(postRequest) 81 | const activityUrl = url.resolve(distbinUrl, first(postResponse.headers.location)) 82 | return activityUrl 83 | } 84 | 85 | function loremIpsum() { 86 | const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt " + 87 | "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " + 88 | "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore " + 89 | "eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt " + 90 | "mollit anim id est laborum." 91 | // post a random number of sentences to vary length 92 | const sentences = text.split(". ") 93 | const truncated = sentences.slice(Math.floor(Math.random() * sentences.length)).join(". ") 94 | return truncated 95 | } 96 | -------------------------------------------------------------------------------- /bin/server.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as express from "express" 3 | import * as fs from "fs" 4 | import * as http from "http"; 5 | import {IncomingMessage, Server, ServerRequest, ServerResponse} from "http" 6 | import * as morgan from "morgan" 7 | import * as os from "os" 8 | import * as path from "path" 9 | import * as portfinder from "portfinder" 10 | import * as querystring from "querystring" 11 | import * as url from "url" 12 | import distbin from "../" 13 | import createDistbinConfig from "../config" 14 | import * as distbinHtml from "../src/distbin-html" 15 | import { JSONFileMapAsync } from "../src/filemap" 16 | import { createLogger } from "../src/logger" 17 | import { debuglog, denodeify, first, readableToString, sendRequest } from "../src/util" 18 | 19 | const logger = createLogger("bin/server") 20 | 21 | // Run tests if this file is executed 22 | if (require.main === module) { 23 | process.on("unhandledRejection", (err) => { 24 | logger.error("Unhandled Promise rejection", err) 25 | throw err; 26 | }) 27 | runServer() 28 | .then(() => process.exit()) 29 | .catch((err) => { 30 | logger.error("Uncaught Error in runServer", err) 31 | process.exit(1) 32 | }) 33 | } 34 | 35 | async function runServer() { 36 | Object.keys({ 37 | SIGINT: 2, 38 | SIGTERM: 15, 39 | }).forEach((signal: NodeJS.Signals) => { 40 | process.on(signal, () => { 41 | process.exit() 42 | }); 43 | }); 44 | const distbinConfig = await createDistbinConfig() 45 | const port = distbinConfig.port || await portfinder.getPortPromise() 46 | if ( ! port) { 47 | throw new Error("Provide required PORT environment variable to configure distbin HTTP port") 48 | } 49 | 50 | const externalUrl = distbinConfig.externalUrl || `http://localhost:${port}` 51 | const internalUrl = distbinConfig.internalUrl || `http://localhost:${port}` 52 | const apiHandler = distbin(Object.assign( 53 | distbinConfig, 54 | ( ! distbinConfig.externalUrl ) && { externalUrl }, 55 | )) 56 | 57 | // api 58 | const apiServer = http.createServer(apiHandler) 59 | const apiServerUrl = await listen(apiServer) 60 | 61 | function logMiddleware(next: (...args: any[]) => any) { 62 | return async (req: express.Request, res: express.Response) => { 63 | const morganMode = process.env.DISTBIN_MORGAN_MODE || (process.env.NODE_ENV === "production" ? "combined" : "dev") 64 | return morgan(morganMode, /* 65 | // I don't actually want to prefix stderr with the logger name. It makes it nonstandard to parse. 66 | // But this is how you'd do it. 67 | { 68 | stream: new Writable({ 69 | write: (chunk, encoding, callback) => { 70 | logger.info(chunk.toString()) 71 | callback() 72 | } 73 | }) 74 | }*/)(req, res, async (err: Error) => { 75 | if (err) { logger.error("error in distbin/bin/server logMiddleware", err) } 76 | await next(req, res) 77 | }) 78 | } 79 | 80 | } 81 | 82 | // html 83 | const htmlServer = http.createServer(logMiddleware(distbinHtml.createHandler({ 84 | apiUrl: apiServerUrl, 85 | externalUrl, 86 | internalUrl }))) 87 | const htmlServerUrl = await listen(htmlServer) 88 | 89 | // mainServer delegates to htmlHandler or distbin api handler based on Accept header 90 | // of request 91 | // #TODO this is awkard. Maybe the 'home page module' at / should now how to content negotiate, not this. 92 | // But not sure best way to do that without making the api part depend on the html part 93 | const mainServer = http.createServer((req, res) => { 94 | // htmlHandler only supports '/' right now (#TODO) 95 | const acceptHeader = first(req.headers.accept) 96 | const preference = (acceptHeader 97 | ? acceptHeader.split(",") 98 | : []).find((mime: string) => ["text/html", "application/json"].includes(mime)) // TODO wtf? 99 | // Depending on 'Accept' header, try candidate backends in a certain order (e.g. html first) 100 | let prioritizedBackends: string[]; 101 | switch (preference) { 102 | case "text/html": 103 | prioritizedBackends = [htmlServerUrl, apiServerUrl] 104 | break; 105 | default: 106 | prioritizedBackends = [apiServerUrl, htmlServerUrl] 107 | } 108 | attemptBackends(prioritizedBackends, req, res) 109 | // preference is html 110 | // proxy to htmlServer 111 | // const apiServerResponse = await forward(req, htmlServerUrl) 112 | // if (apiServerResponse.statusCode === 404) { 113 | // const htmlServerResponse = await forward(req, apiServerUrl) 114 | // proxyResponse(htmlServerResponse, res) 115 | // return 116 | // } 117 | }) 118 | // listen 119 | const mainServerUrl = await listen(mainServer, port) 120 | /* tslint:disable-next-line:no-console */ 121 | console.log(externalUrl) 122 | // now just like listen 123 | await new Promise(() => { 124 | // pass 125 | }) 126 | } 127 | 128 | async function forwardResponse(res: IncomingMessage, toRes: ServerResponse) { 129 | toRes.writeHead(res.statusCode, res.headers) 130 | res.pipe(toRes) 131 | } 132 | 133 | function forwardRequest(req: ServerRequest, toUrl: string): Promise { 134 | const reqToForward = http.request(Object.assign(url.parse(toUrl), { 135 | headers: req.headers, 136 | method: req.method, 137 | path: req.url, 138 | })) 139 | return new Promise((resolve, reject) => { 140 | req.pipe(reqToForward).on("finish", () => { 141 | sendRequest(reqToForward) 142 | .then(resolve) 143 | .catch(reject); 144 | }) 145 | }) 146 | } 147 | 148 | function attemptBackends(backends: string[] = [], req: ServerRequest, res: ServerResponse) { 149 | if ( ! backends.length) { 150 | res.writeHead(404) 151 | res.end() 152 | return 153 | } 154 | const [candidateBackendUrl, ...nextBackends] = backends; 155 | forwardRequest(req, candidateBackendUrl) 156 | .then((candidateResponse: IncomingMessage) => { 157 | switch (candidateResponse.statusCode) { 158 | case 404: 159 | return attemptBackends(nextBackends, req, res) 160 | default: 161 | return forwardResponse(candidateResponse, res) 162 | } 163 | }) 164 | } 165 | 166 | function listen(server: Server, port: number|string= 0): Promise { 167 | return new Promise((resolve, reject) => server.listen(port, (err: Error) => { 168 | if (err) { return reject(err) } 169 | resolve(`http://localhost:${server.address().port}`) 170 | })) 171 | } 172 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | import { ASObject } from "../test/types"; 2 | import { get } from 'lodash' 3 | import { JSONFileMapAsync, IAsyncMap } from '../src/filemap' 4 | import * as fs from 'fs' 5 | import * as os from 'os' 6 | import * as path from 'path' 7 | const { debuglog, denodeify } = require('../src/util'); 8 | 9 | type InboxFilter = (obj: ASObject) => Promise 10 | 11 | interface IDistbinConfig { 12 | activities: IAsyncMap 13 | deliverToLocalhost: Boolean 14 | externalUrl?: string 15 | internalUrl?: string 16 | inbox: IAsyncMap 17 | inboxFilter: InboxFilter 18 | port?: number 19 | } 20 | 21 | export default async (): Promise => { 22 | const dbDir = await initDbDir(process.env.DB_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 'distbin-'))) 23 | debuglog("using db directory", dbDir) 24 | return { 25 | activities: new JSONFileMapAsync(path.join(dbDir, 'activities/')), 26 | deliverToLocalhost: ('DISTBIN_DELIVER_TO_LOCALHOST' in process.env) 27 | ? JSON.parse(process.env.DISTBIN_DELIVER_TO_LOCALHOST) 28 | : process.env.NODE_ENV !== 'production', 29 | externalUrl: process.env.EXTERNAL_URL, 30 | internalUrl: process.env.INTERNAL_URL, 31 | inbox: new JSONFileMapAsync(path.join(dbDir, 'inbox/')), 32 | inboxFilter: objectContentFilter(['viagra']), 33 | port: parsePort(process.env.PORT || process.env.npm_package_config_port), 34 | } 35 | } 36 | 37 | function parsePort (portStr: string|undefined): number|undefined { 38 | const portNum = parseInt(portStr) 39 | if (isNaN(portNum)) return 40 | return portNum 41 | } 42 | 43 | /** 44 | * Create an inboxFilter that blocks incoming activities whose .object.content contains any of the provided substrings 45 | * @param shouldNotContain list of substrings that incoming objects must not have in their content 46 | */ 47 | function objectContentFilter (shouldNotContain: string[]): (obj: ASObject) => Promise { 48 | return async (obj: ASObject) => { 49 | const content: string = get(obj, 'object.content', '').toLowerCase() 50 | return ! shouldNotContain.some(substring => { 51 | return content.includes(substring) 52 | }) 53 | } 54 | } 55 | 56 | async function initDbDir (dbDir: string): Promise { 57 | // ensure subdirs exist 58 | await Promise.all(['activities', 'inbox'].map(dir => { 59 | return denodeify(fs.mkdir)(path.join(dbDir, dir)) 60 | .catch((err: NodeJS.ErrnoException) => { 61 | switch (err.code) { 62 | case 'EEXIST': 63 | // folder exists, no prob 64 | return; 65 | } 66 | throw err; 67 | }) 68 | })); 69 | return dbDir 70 | } 71 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | networks: 4 | public: {} 5 | 6 | volumes: 7 | distbin-db: {} 8 | 9 | services: 10 | distbin: 11 | build: . 12 | networks: 13 | - public 14 | ports: 15 | - 80 16 | volumes: 17 | - distbin-db:/distbin-db:rw 18 | -------------------------------------------------------------------------------- /etc/distbin-nginx-subpath/README.md: -------------------------------------------------------------------------------- 1 | # distbin/etc/distbin-nginx-subpath 2 | 3 | This demonstrates how to host distbin at a 'subpath' like `yourdomain.com/yourSubpath`. 4 | 5 | Motivated by this GitHub issue: https://github.com/gobengo/distbin/issues/20 6 | 7 | It uses nginx as a reverse-proxy. End-user requests first hit nginx. If the HTTP request path starts with '/distbin/', nginx will remove that prefix from the request and forward the request to the running distbin process along a private network. 8 | 9 | distbin itself is run with the environment variable `EXTERNAL_URL=http://localhost:8001/distbin/` set. This allows distbin to render links to the prefixed URL without having to resort to bug-prone URL rewriting of the distbin-html HTML. 10 | 11 | ## Usage 12 | 13 | From this directory, `docker-compose up` and access `http://localhost:8001`. -------------------------------------------------------------------------------- /etc/distbin-nginx-subpath/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | networks: 4 | public: {} 5 | private: {} 6 | 7 | volumes: 8 | distbin-db: {} 9 | 10 | services: 11 | distbin-subpath-distbin: 12 | command: npm run start:ts-node 13 | environment: 14 | # Because with this docker networking setup, the running container cannot access EXTERNAL_URL 15 | - INTERNAL_URL=http://distbin-subpath-distbin:80/ 16 | - EXTERNAL_URL=http://localhost:8001/distbin/ 17 | - NODE_DEBUG=distbin 18 | - LOG_LEVEL=debug 19 | # - DISTBIN_DELIVER_TO_LOCALHOST=false 20 | build: ../../ 21 | networks: 22 | - private 23 | ports: 24 | - 80 25 | volumes: 26 | - distbin-db:/distbin-db:rw 27 | # - .:/home/distbin/app 28 | 29 | distbin-subpath: 30 | depends_on: 31 | - distbin-subpath-distbin 32 | image: nginx:latest 33 | networks: 34 | - public 35 | - private 36 | volumes: 37 | - ./nginx.conf:/etc/nginx/nginx.conf 38 | ports: 39 | - 8001:80 40 | -------------------------------------------------------------------------------- /etc/distbin-nginx-subpath/nginx.conf: -------------------------------------------------------------------------------- 1 | events { worker_connections 1024; } 2 | 3 | http { 4 | sendfile on; 5 | rewrite_log on; 6 | error_log /dev/stdout notice; 7 | access_log /dev/stdout; 8 | ignore_invalid_headers off; 9 | 10 | # upstream distbin-subpath-distbin { 11 | # server distbin-subpath-distbin:80; 12 | # } 13 | 14 | server { 15 | location /distbin/ { 16 | rewrite ^/distbin/(.*) /$1 break; 17 | proxy_pass http://distbin-subpath-distbin/; 18 | proxy_pass_request_headers on; 19 | proxy_redirect ~^/(.*) $scheme://$http_host/distbin/$1; 20 | proxy_set_header Host $http_host; 21 | proxy_set_header X-Real-IP $remote_addr; 22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 23 | # proxy_set_header X-Forwarded-Host $server_name; 24 | # proxy_set_header X-Forwarded-Host $host; 25 | proxy_set_header X-Forwarded-Proto $scheme; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src') 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "distbin", 3 | "version": "1.3.0", 4 | "description": "Post bin with distributed social features.", 5 | "main": "src", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "@types/accepts": "^1.3.5", 11 | "@types/dompurify": "0.0.31", 12 | "@types/highlight.js": "^9.12.3", 13 | "@types/jsdom": "^11.0.5", 14 | "@types/lodash": "^4.14.85", 15 | "@types/marked": "^0.3.0", 16 | "@types/morgan": "^1.7.35", 17 | "@types/node-uuid": "0.0.28", 18 | "@types/parse-link-header": "^1.0.0", 19 | "@types/winston": "^2.3.7", 20 | "accepts": "^1.3.3", 21 | "dompurify": "^0.8.4", 22 | "highlight.js": "^9.15.6", 23 | "jsdom": "^9.9.1", 24 | "jsonld": "^0.4.11", 25 | "jsonld-rdfa-parser": "^1.5.1", 26 | "lodash": "^4.17.4", 27 | "marked": "^0.3.19", 28 | "morgan": "^1.9.0", 29 | "node-fetch": "^1.6.3", 30 | "node-uuid": "^1.4.7", 31 | "parse-link-header": "^0.4.1", 32 | "portfinder": "^1.0.13", 33 | "url-regex": "^5.0.0", 34 | "uuid": "^3.1.0", 35 | "winston": "^2.4.0" 36 | }, 37 | "devDependencies": { 38 | "@types/node": "^8.10.19", 39 | "@types/node-fetch": "^2.1.1", 40 | "copyfiles": "^1.2.0", 41 | "eslint": "^3.7.1", 42 | "eslint-config-standard": "^6.2.0", 43 | "eslint-plugin-no-only-tests": "^2.0.0", 44 | "eslint-plugin-promise": "^3.0.0", 45 | "eslint-plugin-standard": "^2.0.1", 46 | "prettier": "^1.16.4", 47 | "ts-node": "^6.1.0", 48 | "tsc-watch": "^1.0.22", 49 | "tslint": "^5.10.0", 50 | "tslint-config-prettier": "^1.18.0", 51 | "tslint-eslint-rules": "^5.4.0", 52 | "tslint-microsoft-contrib": "^6.1.0", 53 | "typescript": "^2.9.1", 54 | "typescript-eslint-parser": "^8.0.1" 55 | }, 56 | "peerDependencies": { 57 | "hoek": "^4.2.1" 58 | }, 59 | "config": { 60 | "port": 8000 61 | }, 62 | "prettier": { 63 | "trailingComma": "all" 64 | }, 65 | "scripts": { 66 | "lint": "tslint -c tslint.json 'src/**/*.ts' 'test/**/*.ts' 'bin/**/*.ts'", 67 | "lint-fix": "tslint --fix -c tslint.json 'src/**/*.ts' 'test/**/*.ts' 'bin/**/*.ts'", 68 | "prettier": "prettier '{package.json,tsconfig.json,src/**/*.{ts,tsx}}' --write", 69 | "test": "ts-node test", 70 | "start": "node ./dist/bin/server", 71 | "start-dev": "tsc-watch --onSuccess 'npm start'", 72 | "start:ts-node": "ts-node bin/server.ts", 73 | "tsc": "tsc", 74 | "build": "tsc && npm run build.copyfiles", 75 | "build.copyfiles": "copyfiles './src/**/*.json' dist/" 76 | }, 77 | "author": "", 78 | "license": "ISC" 79 | } 80 | -------------------------------------------------------------------------------- /src/activitypub.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from "http"; 2 | import * as http from "http"; 3 | import * as https from "https"; 4 | import { get } from "lodash"; 5 | import * as parseLinkHeader from "parse-link-header"; 6 | import * as url from "url"; 7 | import { UrlObject } from "url"; 8 | import * as createUrlRegex from "url-regex"; 9 | import { ASJsonLdProfileContentType } from "./activitystreams"; 10 | import { 11 | activitySubtypes, 12 | ASValue, 13 | isASLink, 14 | isASObject, 15 | } from "./activitystreams/types"; 16 | import { 17 | Activity, 18 | ASObject, 19 | Extendable, 20 | isActivity, 21 | JSONLD, 22 | LDValue, 23 | } from "./types"; 24 | import { jsonld } from "./util"; 25 | import { debuglog, flatten } from "./util"; 26 | import { request } from "./util"; 27 | import { rdfaToJsonLd } from "./util"; 28 | import { 29 | ensureArray, 30 | followRedirects, 31 | jsonldAppend, 32 | makeErrorClass, 33 | readableToString, 34 | sendRequest, 35 | } from "./util"; 36 | 37 | import fetch from "node-fetch"; 38 | import { createLogger } from "../src/logger"; 39 | const logger = createLogger("activitypub"); 40 | 41 | export const publicCollectionId = 42 | "https://www.w3.org/ns/activitystreams#Public"; 43 | 44 | // Given an AS2 Object, return whether it appears to be an "subtype of Activity" 45 | // as required for https://w3c.github.io/activitypub/#object-without-create 46 | // #TODO - What if it's an extension activity that describes itself via 47 | // rdfs as a subtype of Activity? 48 | export const as2ObjectIsActivity = (obj: ASObject) => { 49 | return ensureArray(obj.type).some(t => activitySubtypes.includes(t)); 50 | }; 51 | 52 | export const getASId = (o: LDValue) => { 53 | if (typeof o === "string") { 54 | return o; 55 | } 56 | if (typeof o === "object") { 57 | return o.id; 58 | } 59 | // tslint:disable-next-line:no-unused-expression 60 | o as never; 61 | }; 62 | 63 | const flattenAnyArrays = (arr: Array): T[] => { 64 | const flattened: T[] = arr.reduce((all, o): T[] => { 65 | if (o instanceof Array) { 66 | return all.concat(o); 67 | } 68 | return all.concat([o]); 69 | }, []); 70 | return flattened; 71 | }; 72 | 73 | /** 74 | * Get the targets of an AS Object. Those who might be interested about getting notified. 75 | * @param o 76 | * @param shouldFetch - whether to fetch related objects that are only mentioned by URL 77 | */ 78 | export const objectTargets = async ( 79 | o: ASObject, 80 | recursionLimit: number, 81 | shouldFetch: boolean = false, 82 | urlRewriter: (u: string) => string, 83 | ): Promise => { 84 | logger.debug("start objectTargets", recursionLimit, o); 85 | const audience = [ 86 | ...(await objectTargetsNoRecurse(o, shouldFetch, urlRewriter)), 87 | ...objectProvenanceAudience(o), 88 | ...targetedAudience(o), 89 | ]; 90 | logger.debug("objectTargets got audience", audience); 91 | const recursedAudience = recursionLimit 92 | ? flattenAnyArrays( 93 | await Promise.all( 94 | audience.map(async (audienceMember: ASObject) => { 95 | const recursedTargets = await objectTargets( 96 | audienceMember, 97 | recursionLimit - 1, 98 | shouldFetch, 99 | urlRewriter, 100 | ); 101 | return recursedTargets; 102 | }), 103 | ), 104 | ) 105 | : []; 106 | // logger.debug('objectTargets', { audience, recursedAudience, recursionLimit, activity }) 107 | const targets = [...audience, ...recursedAudience]; 108 | const deduped = Array.from(new Set(targets)); 109 | return deduped; 110 | }; 111 | 112 | /** 113 | * Get the targets of a single level of an AS Object. Don't recurse (see objectTargets) 114 | * @param o 115 | * @param shouldFetch - whether to fetch related objects that are only mentioned by URL 116 | */ 117 | export const objectTargetsNoRecurse = async ( 118 | o: ASObject, 119 | shouldFetch: boolean = false, 120 | urlRewriter: (u: string) => string, 121 | // relatedObjectTargetedAudience is a MAY in the spec. 122 | // And if you leave it on and start replying to long chains, 123 | // you'll end up having to deliver to every ancestor, which takes a long time in 124 | // big threads. So you might want to disable it to get a smaller result set 125 | { 126 | relatedObjectTargetedAudience = true, 127 | }: { relatedObjectTargetedAudience?: boolean } = {}, 128 | ): Promise => { 129 | /* Clients SHOULD look at any objects attached to the new Activity via the object, 130 | target, inReplyTo and/or tag fields, retrieve their actor or attributedTo properties, 131 | and MAY also retrieve their addressing properties, and add these 132 | to the to or cc fields of the new Activity being created. */ 133 | // logger.debug('isActivity(o)', isActivity(o), o) 134 | const related = flattenAnyArrays([ 135 | isActivity(o) && o.object, 136 | isActivity(o) && o.target, 137 | // this isn't really mentioned in the spec but required to get working how I'd expect. 138 | isActivity(o) && o.type === "Create" && get(o, "object.inReplyTo"), 139 | o.inReplyTo, 140 | o.tag, 141 | ]).filter(Boolean); 142 | logger.debug("o.related", related); 143 | const relatedObjects = (await Promise.all( 144 | related.map(async objOrUrl => { 145 | if (typeof objOrUrl === "object") { 146 | return objOrUrl; 147 | } 148 | if (!shouldFetch) { 149 | return; 150 | } 151 | // fetch url to get an object 152 | const audienceUrl: string = objOrUrl; 153 | // need to fetch it by url 154 | logger.debug("about to fetch for audienceUrl", { 155 | audienceUrl, 156 | rewritten: urlRewriter(audienceUrl), 157 | }); 158 | const res = await sendRequest( 159 | request( 160 | Object.assign(url.parse(urlRewriter(audienceUrl)), { 161 | headers: { 162 | accept: ASJsonLdProfileContentType, 163 | }, 164 | }), 165 | ), 166 | ); 167 | logger.debug("fetched audienceUrl", audienceUrl); 168 | if (res.statusCode !== 200) { 169 | logger.warn( 170 | "got non-200 response when fetching ${obj} as part of activityAudience()", 171 | ); 172 | return; 173 | } 174 | const body = await readableToString(res); 175 | const resContentType = res.headers["content-type"]; 176 | switch (resContentType) { 177 | case ASJsonLdProfileContentType: 178 | case "application/json": 179 | try { 180 | return JSON.parse(body); 181 | } catch (error) { 182 | logger.error( 183 | "Couldn't parse fetched response body as JSON when determining activity audience", 184 | { body }, 185 | error, 186 | ); 187 | return; 188 | } 189 | default: 190 | logger.warn( 191 | `Unexpected contentType=${resContentType} of response when fetching ` + 192 | `${audienceUrl} to determine activityAudience`, 193 | ); 194 | return; 195 | } 196 | }), 197 | )).filter(Boolean); 198 | // logger.debug('o.relatedObjects', relatedObjects) 199 | 200 | const relatedCreators: ASValue[] = flattenAnyArrays( 201 | relatedObjects.map(objectProvenanceAudience), 202 | ).filter(Boolean); 203 | const relatedAudience: ASValue[] = relatedObjectTargetedAudience 204 | ? flattenAnyArrays( 205 | relatedObjects.map(ro => isASObject(ro) && targetedAudience(ro)), 206 | ).filter(Boolean) 207 | : []; 208 | 209 | const targets: ASValue[] = [...relatedCreators, ...relatedAudience]; 210 | return targets; 211 | }; 212 | 213 | /** 214 | * Given a resource, return a list of other resources that helped create the original one 215 | * @param o - AS Object to get provenance audience for 216 | */ 217 | const objectProvenanceAudience = (o: ASObject): ASValue[] => { 218 | const actor = isActivity(o) && o.actor; 219 | const attributedTo = isASObject(o) && o.attributedTo; 220 | return [actor, attributedTo].filter(Boolean); 221 | }; 222 | 223 | /** 224 | * Given a resource, return a list of other resources that are explicitly targeted using audience targeting properties 225 | * @param o - AS Object to get targeted audience for 226 | */ 227 | export const targetedAudience = (object: ASObject | Activity) => { 228 | const targeted = flattenAnyArrays([ 229 | object.to, 230 | object.bto, 231 | object.cc, 232 | object.bcc, 233 | ]).filter(Boolean); 234 | const deduped = Array.from(new Set([].concat(targeted))); 235 | return deduped; 236 | }; 237 | 238 | /** 239 | * Given an activity, return an updated version of that activity that has been client-addressed. 240 | * So then you can submit the addressed activity to an outbox and make sure it's delivered to everyone who might care. 241 | * @param activity 242 | */ 243 | export const clientAddressedActivity = async ( 244 | activity: Activity, 245 | recursionLimit: number, 246 | shouldFetch: boolean = false, 247 | urlRewriter: (urlToFetch: string) => string, 248 | ): Promise => { 249 | const audience = await objectTargets( 250 | activity, 251 | recursionLimit, 252 | shouldFetch, 253 | urlRewriter, 254 | ); 255 | const audienceIds = audience.map(getASId); 256 | return Object.assign({}, activity, { 257 | cc: Array.from(new Set(jsonldAppend(activity.cc, audienceIds))).filter( 258 | Boolean, 259 | ), 260 | }); 261 | }; 262 | 263 | // Create a headers map for http.request() incl. any specced requirements for ActivityPub Client requests 264 | export const clientHeaders = (headers = {}) => { 265 | const requirements = { 266 | // The client MUST specify an Accept header with the 267 | // application/ld+json; profile="https://www.w3.org/ns/activitystreams" media type 268 | // in order to retrieve the activity. 269 | // #critique: This is weird because AS2's official mimetype is 270 | // application/activity+json, and the ld+json + profile is only a SHOULD, 271 | // but in ActivityPub this is switched 272 | accept: `${ASJsonLdProfileContentType}"`, 273 | }; 274 | if ( 275 | Object.keys(headers) 276 | .map(h => h.toLowerCase()) 277 | .includes("accept") 278 | ) { 279 | throw new Error( 280 | `ActivityPub Client requests can't include custom Accept header. ` + 281 | `Must always be the same value of "${requirements.accept}"`, 282 | ); 283 | } 284 | return Object.assign(requirements, headers); 285 | }; 286 | 287 | const deliveryErrors = (exports.deliveryErrors = { 288 | // Succeeded in delivering, but response was an error 289 | DeliveryErrorResponse: makeErrorClass("DeliveryErrorResponse"), 290 | // Found an inbox, but failed to POST delivery to it 291 | DeliveryRequestFailed: makeErrorClass("DeliveryRequestFailed"), 292 | // Target could be fetched, but couldn't determine any .inbox 293 | InboxDiscoveryFailed: makeErrorClass("InboxDiscoveryFailed"), 294 | // At least one delivery did not succeed. Try again later? 295 | SomeDeliveriesFailed: makeErrorClass("SomeDeliveriesFailed", function( 296 | msg: string, 297 | failures: Error[], 298 | successes: string[], 299 | ) { 300 | this.failures = failures; 301 | this.successes = successes; 302 | }), 303 | // Failed to parse target HTTP response as JSON 304 | TargetParseFailed: makeErrorClass("TargetParseFailed"), 305 | // Failed to send HTTP request to a target 306 | TargetRequestFailed: makeErrorClass("TargetRequestFailed"), 307 | }); 308 | 309 | const fetchProfile = (exports.fetchProfile = async (target: string) => { 310 | const targetProfileRequest = request( 311 | Object.assign(url.parse(target), { 312 | headers: { 313 | accept: `${ASJsonLdProfileContentType},text/html`, 314 | }, 315 | }), 316 | ); 317 | logger.debug("fetchProfile " + target); 318 | let targetProfileResponse; 319 | try { 320 | targetProfileResponse = await sendRequest(targetProfileRequest); 321 | } catch (e) { 322 | throw new deliveryErrors.TargetRequestFailed(e.message); 323 | } 324 | logger.debug( 325 | `res ${targetProfileResponse.statusCode} fetchProfile for ${target}`, 326 | ); 327 | 328 | switch (targetProfileResponse.statusCode) { 329 | case 200: 330 | // cool 331 | break; 332 | default: 333 | throw new deliveryErrors.TargetRequestFailed( 334 | `Got unexpected status code ${ 335 | targetProfileResponse.statusCode 336 | } when requesting ${target} to fetchProfile`, 337 | ); 338 | } 339 | 340 | return targetProfileResponse; 341 | }); 342 | 343 | export const discoverOutbox = async (target: string) => { 344 | const profileResponse = await fetchProfile(target); 345 | const outbox = url.resolve(target, await outboxFromResponse(profileResponse)); 346 | return outbox; 347 | }; 348 | 349 | async function outboxFromResponse(res: IncomingMessage) { 350 | const contentTypeHeaders = ensureArray(res.headers["content-type"]); 351 | const contentType = contentTypeHeaders 352 | .map((contentTypeValue: string) => contentTypeValue.split(";")[0]) 353 | .filter(Boolean)[0]; 354 | const body = await readableToString(res); 355 | switch (contentType) { 356 | case "application/json": 357 | const targetProfile = (() => { 358 | try { 359 | return JSON.parse(body); 360 | } catch (e) { 361 | throw new deliveryErrors.TargetParseFailed(e.message); 362 | } 363 | })(); 364 | // #TODO be more JSON-LD aware when looking for outbox 365 | return targetProfile.outbox; 366 | default: 367 | throw new Error( 368 | `Don't know how to parse ${contentType} to determine outbox URL`, 369 | ); 370 | } 371 | } 372 | 373 | // deliver an activity to a target 374 | const deliverActivity = async ( 375 | activity: Activity, 376 | target: string, 377 | { deliverToLocalhost }: { deliverToLocalhost: boolean }, 378 | ) => { 379 | // discover inbox 380 | logger.debug("req inbox discovery " + target); 381 | const targetProfileResponse = await (async () => { 382 | try { 383 | return await followRedirects( 384 | Object.assign(url.parse(target), { 385 | headers: { 386 | accept: `${ASJsonLdProfileContentType}, text/html`, 387 | }, 388 | }), 389 | ); 390 | } catch (error) { 391 | logger.error( 392 | `Error delivering activity to target=${target}. ` + 393 | `This is normal if the target doesnt speak great ActivityPub.`, 394 | error, 395 | ); 396 | throw new deliveryErrors.TargetRequestFailed(error.message); 397 | } 398 | })(); 399 | 400 | logger.debug( 401 | `res ${targetProfileResponse.statusCode} inbox discovery for ${target}`, 402 | ); 403 | 404 | switch (targetProfileResponse.statusCode) { 405 | case 200: 406 | // cool 407 | break; 408 | default: 409 | throw new deliveryErrors.TargetRequestFailed( 410 | `Got unexpected status code ${ 411 | targetProfileResponse.statusCode 412 | } when requesting ` + `${target} to determine inbox URL`, 413 | ); 414 | } 415 | 416 | logger.debug(`deliverActivity to target ${target}`); 417 | const body = await readableToString(targetProfileResponse); 418 | const contentType = ensureArray(targetProfileResponse.headers["content-type"]) 419 | .map((contentTypeValue: string) => contentTypeValue.split(";")[0]) 420 | .filter(Boolean)[0]; 421 | let inbox: string = 422 | inboxFromHeaders(targetProfileResponse) || 423 | (await inboxFromBody(body, contentType)); 424 | if (inbox) { 425 | inbox = url.resolve(target, inbox); 426 | } 427 | if (!inbox) { 428 | throw new deliveryErrors.InboxDiscoveryFailed( 429 | "No .inbox found for target " + target, 430 | ); 431 | } 432 | 433 | // post to inbox 434 | const parsedInboxUrl = url.parse(inbox); 435 | 436 | // https://w3c.github.io/activitypub/#security-localhost 437 | if (parsedInboxUrl.hostname === "localhost" && !deliverToLocalhost) { 438 | throw new Error( 439 | "I will not deliver to localhost (protocol feature server:security-considerations:do-not-post-to-localhost)", 440 | ); 441 | } 442 | 443 | const deliveryRequest = request( 444 | Object.assign(parsedInboxUrl, { 445 | headers: { 446 | "content-type": ASJsonLdProfileContentType, 447 | }, 448 | method: "post", 449 | }), 450 | ); 451 | deliveryRequest.write(JSON.stringify(activity)); 452 | 453 | let deliveryResponse; 454 | try { 455 | deliveryResponse = await sendRequest(deliveryRequest); 456 | } catch (e) { 457 | throw new deliveryErrors.DeliveryRequestFailed(e.message); 458 | } 459 | const deliveryResponseBody = await readableToString(deliveryResponse); 460 | logger.debug( 461 | `ldn notify res ${ 462 | deliveryResponse.statusCode 463 | } ${inbox} ${deliveryResponseBody.slice(0, 100)}`, 464 | ); 465 | if ( 466 | deliveryResponse.statusCode >= 400 && 467 | deliveryResponse.statusCode <= 599 468 | ) { 469 | // client or server error 470 | throw new deliveryErrors.DeliveryErrorResponse( 471 | `${ 472 | deliveryResponse.statusCode 473 | } response from ${inbox}\nResponse Body:\n${deliveryResponseBody}`, 474 | ); 475 | } 476 | // #TODO handle retry/timeout? 477 | return target; 478 | }; 479 | 480 | // Given an activity, determine its targets and deliver to the inbox of each 481 | // target 482 | export const targetAndDeliver = async ( 483 | activity: Activity, 484 | targets: string[], 485 | deliverToLocalhost: boolean, 486 | urlRewriter: (u: string) => string, 487 | modifyTargets: (targets: string[]) => string[] = (ts) => ts, 488 | ) => { 489 | logger.debug("start targetAndDeliver"); 490 | targets = modifyTargets( 491 | targets || 492 | (await objectTargets(activity, 0, false, urlRewriter)) 493 | .map(t => { 494 | const targetUrl = getASId(t); 495 | if (!targetUrl) { 496 | logger.debug( 497 | "Cant determine URL to deliver to for target, so skipping", 498 | t, 499 | ); 500 | } 501 | return targetUrl; 502 | }) 503 | .filter(Boolean) 504 | ); 505 | logger.debug("targetAndDeliver targets", targets); 506 | const deliveries: string[] = []; 507 | const failures: Error[] = []; 508 | await Promise.all( 509 | targets.map( 510 | (target): Promise => { 511 | // Don't actually deliver to publicCollection URI as it is 'special' 512 | if (target === exports.publicCollectionId) { 513 | return Promise.resolve(target); 514 | } 515 | return deliverActivity(activity, urlRewriter(target), { 516 | deliverToLocalhost, 517 | }) 518 | .then(d => deliveries.push(d)) 519 | .catch(e => failures.push(e)); 520 | }, 521 | ), 522 | ); 523 | logger.debug("finished targetAndDeliver", { failures, deliveries }); 524 | if (failures.length) { 525 | logger.debug("failures delivering " + failures.map(e => e.stack)); 526 | throw new deliveryErrors.SomeDeliveriesFailed( 527 | "SomeDeliveriesFailed", 528 | failures, 529 | deliveries, 530 | ); 531 | } 532 | return deliveries; 533 | }; 534 | 535 | export const inboxUrl = async (subjectUrl: string) => { 536 | const subjectResponse = await fetch(subjectUrl); 537 | const subject = await subjectResponse.json(); 538 | const inbox = subject.inbox; 539 | if (!inbox) { 540 | return inbox; 541 | } 542 | const iurl = url.resolve(subjectUrl, inbox); 543 | return iurl; 544 | }; 545 | 546 | function inboxFromHeaders(res: IncomingMessage) { 547 | // look in res Link header 548 | const linkHeaders = ensureArray(res.headers.link); 549 | const inboxLinks = linkHeaders 550 | .map(parseLinkHeader) 551 | .filter(Boolean) 552 | .map((parsed: any) => { 553 | return parsed["http://www.w3.org/ns/ldp#inbox"]; 554 | }) 555 | .filter(Boolean); 556 | let inboxLink; 557 | if (Array.isArray(inboxLinks)) { 558 | if (inboxLinks.length > 1) { 559 | logger.warn( 560 | "More than 1 LDN inbox found, but only using 1 for now", 561 | inboxLinks, 562 | ); 563 | inboxLink = inboxLinks[0]; 564 | } 565 | } else { 566 | inboxLink = inboxLinks; 567 | } 568 | return inboxLink; 569 | } 570 | 571 | /** 572 | * Determine the ActivityPub Inbox of a fetched resource 573 | * @param body - The fetched resource 574 | * @param contentType - HTTP Content Type header of fetched resource 575 | * 576 | * This will look for the following kinds of inboxes, and return the first one it finds: 577 | * * a 'direct' inbox 578 | * * an actor, which is a separate resource, that has an inbox for activities related to 579 | * objects that actor is related to 580 | * 581 | * @TODO (bengo): Allow returning all inboxes we can find 582 | */ 583 | async function inboxFromBody(body: string, contentType: string) { 584 | try { 585 | const directInbox = await directInboxFromBody(body, contentType); 586 | if (directInbox) { 587 | return directInbox; 588 | } 589 | } catch (error) { 590 | logger.debug("Error looking for directInbox (Moving on).", error); 591 | } 592 | const actorInboxes = await actorInboxesFromBody(body, contentType); 593 | if (actorInboxes.length > 1) { 594 | logger.warn( 595 | "Got more than one actorInboxes. Only using first.", 596 | actorInboxes, 597 | ); 598 | } 599 | if (actorInboxes.length) { 600 | return actorInboxes[0]; 601 | } 602 | } 603 | 604 | /** 605 | * Given a resource as a string, determine the inboxes for any actors of the resource 606 | */ 607 | async function actorInboxesFromBody( 608 | body: string, 609 | contentType: string, 610 | ): Promise { 611 | const bodyData = bodyToJsonLd(body, contentType); 612 | const compacted = await jsonld.compact(bodyData, { 613 | "@context": "https://www.w3.org/ns/activitystreams", 614 | }); 615 | const actorUrls = flatten( 616 | ensureArray(bodyData.actor) 617 | .filter(Boolean) 618 | .map(actor => { 619 | if (typeof actor === "string") { 620 | return [actor]; 621 | } else if (actor.url) { 622 | return ensureArray(actor.url); 623 | } else { 624 | logger.debug("Could not determine url from actor", { actor }); 625 | return []; 626 | } 627 | }), 628 | ); 629 | logger.debug("Actor URLs", actorUrls); 630 | const actorInboxes = flatten( 631 | await Promise.all( 632 | actorUrls.map(async actorUrl => { 633 | try { 634 | const res = await fetch(actorUrl, { 635 | headers: { 636 | accept: ASJsonLdProfileContentType, 637 | }, 638 | }); 639 | const actor = await res.json(); 640 | const inbox = actor.inbox; 641 | logger.debug("Actor inbox", inbox); 642 | return ensureArray(inbox).map(inboxRelativeUrl => 643 | url.resolve(actorUrl, inboxRelativeUrl), 644 | ); 645 | } catch (error) { 646 | logger.warn("Error fetching actor to lookup inbox", error); 647 | } 648 | }), 649 | ), 650 | ); 651 | return actorInboxes; 652 | } 653 | 654 | const UnexpectedContentTypeError = makeErrorClass("UnexpectedContentTypeError"); 655 | 656 | /** 657 | * Given a resource as a string + contentType, return a representation of it's linked data as a JSON-LD Object 658 | */ 659 | function bodyToJsonLd(body: string, contentType: string) { 660 | logger.debug("bodyToJsonLd", { contentType }); 661 | switch (contentType) { 662 | case "application/json": 663 | case "application/ld+json": 664 | case "application/activity+json": 665 | const data = JSON.parse(body); 666 | return data; 667 | default: 668 | logger.warn("Unable to bodyToJsonLd due to unexpected contentType", { 669 | contentType, 670 | }); 671 | throw new UnexpectedContentTypeError( 672 | `Dont know how to parse contentType=${contentType}`, 673 | ); 674 | } 675 | } 676 | 677 | /** 678 | * Given a resource as a string, return it's ActivityPub inbox URL (if any) 679 | */ 680 | async function directInboxFromBody(body: string, contentType: string) { 681 | let inboxes; 682 | logger.debug(`inboxFromBody got response contentType=${contentType}`); 683 | switch (contentType) { 684 | case "application/json": 685 | const object = (() => { 686 | try { 687 | return JSON.parse(body); 688 | } catch (e) { 689 | throw new deliveryErrors.TargetParseFailed(e.message); 690 | } 691 | })(); 692 | logger.debug("object", object); 693 | inboxes = ensureArray(object.inbox).filter(Boolean); 694 | break; 695 | case "text/html": 696 | const ld: Array> = await rdfaToJsonLd(body); 697 | const targetSubject = ld.find(x => x["@id"] === "http://localhost/"); 698 | if (!targetSubject) { 699 | logger.debug( 700 | "no targetSubject so no ldb:inbox after checking text/html for ld. got ld", 701 | ld, 702 | ); 703 | inboxes = []; 704 | } else { 705 | inboxes = targetSubject["http://www.w3.org/ns/ldp#inbox"].map( 706 | (i: JSONLD) => i["@id"], 707 | ); 708 | } 709 | break; 710 | case "application/ld+json": 711 | case "application/activity+json": 712 | const obj = JSON.parse(body); 713 | const compacted = await jsonld.compact(obj, { 714 | "@context": [ 715 | "https://www.w3.org/ns/activitystreams", 716 | { 717 | "distbin:inbox": { 718 | "@container": "@set", 719 | "@id": "ldp:inbox", 720 | }, 721 | }, 722 | ], 723 | }); 724 | const compactedInbox = (compacted["distbin:inbox"] || []).map( 725 | (o: { id: string }) => (typeof o === "object" ? o.id : o), 726 | ); 727 | inboxes = compactedInbox.length ? compactedInbox : ensureArray(obj.inbox); 728 | break; 729 | default: 730 | throw new Error( 731 | `Don't know how to parse ${contentType} to determine inbox URL`, 732 | ); 733 | } 734 | if (!inboxes || !inboxes.length) { 735 | logger.debug( 736 | `Could not determine ActivityPub inbox from ${contentType} response`, 737 | ); 738 | return; 739 | } 740 | if (inboxes.length > 1) { 741 | logger.warn( 742 | `Using only first inbox, but there were ${inboxes.length}: ${inboxes}`, 743 | ); 744 | } 745 | const inbox: string = inboxes[0]; 746 | return inbox; 747 | } 748 | -------------------------------------------------------------------------------- /src/activitystreams/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | 3 | export const jsonLdProfile = "https://www.w3.org/ns/activitystreams"; 4 | // application/ld+json; profile="https://www.w3.org/ns/activitystreams" 5 | export const ASJsonLdProfileContentType = `application/ld+json; profile="${jsonLdProfile}"`; 6 | -------------------------------------------------------------------------------- /src/activitystreams/types.ts: -------------------------------------------------------------------------------- 1 | type ISO8601 = string; 2 | type xsdAnyUri = string; 3 | 4 | type OneOrMore = T | T[]; 5 | 6 | // ASLinked Data 7 | type LDIdentifier = xsdAnyUri; 8 | export type LDValue = LDIdentifier | T; 9 | export type LDValues = T | T[]; 10 | export type LDObject = { [P in keyof T]?: LDValues }; 11 | type JSONLDContext = OneOrMore< 12 | | string 13 | | { 14 | "@vocab"?: string; 15 | "@language"?: string; 16 | [key: string]: string | { [key: string]: string }; 17 | } 18 | >; 19 | export class JSONLD { 20 | public "@id": string; 21 | } 22 | 23 | class ASBase { 24 | public "@context"?: JSONLDContext; 25 | } 26 | 27 | // @TODO (bengo): enumerate known values? 28 | type LinkRelation = string; 29 | 30 | export class ASLink { 31 | public type: ASObjectType<"Link">; 32 | public href: string; 33 | public mediaType?: string; 34 | public rel?: LinkRelation; 35 | } 36 | export const Link = ASLink; 37 | 38 | export const isASLink = (obj: any): obj is ASLink => { 39 | return obj.type === "Link"; 40 | }; 41 | 42 | // @TODO (bengo) 43 | type RdfLangString = string; 44 | interface INaturalLanguageValue { 45 | // @TODO (bengo) this could be more specific about keys than just string 46 | [key: string]: string; 47 | } 48 | 49 | type ASObjectType = T | T[]; 50 | export type ASValue = string | ASObject | ASLink; 51 | // W3C ActivityStreams 2.0 52 | export class ASObject extends ASBase { 53 | public attachment?: OneOrMore; 54 | public attributedTo?: LDValue; 55 | public bcc?: LDValue; 56 | public cc?: OneOrMore>; 57 | public content?: string; 58 | public generator?: LDValue; 59 | public id?: string; 60 | public image?: OneOrMore; 61 | public inReplyTo?: LDValue; 62 | public location?: ASObject; 63 | public name?: string; 64 | public nameMap?: INaturalLanguageValue; 65 | public preview?: ASValue; 66 | public published?: ISO8601; 67 | public replies?: LDValue>; 68 | public summary?: string | RdfLangString; 69 | public tag?: ASObject | ASLink; 70 | public to?: LDValue; 71 | public bto?: LDValue; 72 | public type?: ASObjectType; 73 | public url?: OneOrMore; 74 | } 75 | 76 | export const isASObject = (obj: any): obj is ASObject => { 77 | return typeof obj === "object"; 78 | }; 79 | 80 | class ASImage extends ASObject {} 81 | 82 | // https://www.w3.org/TR/activitystreams-vocabulary/#activity-types 83 | export const activitySubtypes = [ 84 | "Accept", 85 | "Add", 86 | "Announce", 87 | "Arrive", 88 | "Block", 89 | "Create", 90 | "Delete", 91 | "Dislike", 92 | "Flag", 93 | "Follow", 94 | "Ignore", 95 | "Invite", 96 | "Join", 97 | "Leave", 98 | "Like", 99 | "Listen", 100 | "Move", 101 | "Offer", 102 | "Question", 103 | "Reject", 104 | "Read", 105 | "Remove", 106 | "TentativeReject", 107 | "TentativeAccept", 108 | "Travel", 109 | "Undo", 110 | "Update", 111 | "View", 112 | ]; 113 | const ActivitySubtypes = strEnum(activitySubtypes); 114 | type ActivitySubtype = keyof typeof ActivitySubtypes; 115 | 116 | export class Activity extends ASObject { 117 | public type: ASObjectType<"Activity" | ActivitySubtype>; 118 | public actor?: ASValue; 119 | public object?: LDValue; 120 | public target?: ASValue; 121 | constructor(props: any) { 122 | super(); 123 | this.type = this.constructor.name; 124 | Object.assign(this, props); 125 | } 126 | } 127 | 128 | export const isActivity = (activity: any): activity is Activity => { 129 | if (typeof activity === "object") { 130 | return activitySubtypes.includes(activity.type); 131 | } 132 | return false; 133 | }; 134 | 135 | export class Collection extends ASObject { 136 | public items?: T[]; 137 | public totalItems?: number; 138 | } 139 | 140 | export class Note extends ASObject { 141 | public type: ASObjectType<"Note">; 142 | } 143 | 144 | export class Place extends ASObject { 145 | public accuracy?: number; 146 | public latitude?: number; 147 | public longitude?: number; 148 | public altitude?: number; 149 | public radius?: number; 150 | public units?: "cm" | "feet" | "inches" | "km" | "m" | "miles" | xsdAnyUri; 151 | } 152 | 153 | function strEnum(o: T[]): { [K in T]: K } { 154 | return o.reduce((res, key) => { 155 | res[key] = key; 156 | return res; 157 | }, Object.create(null)); 158 | } 159 | -------------------------------------------------------------------------------- /src/as2context.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": { 3 | "@vocab": "_:", 4 | "xsd": "http://www.w3.org/2001/XMLSchema#", 5 | "as": "https://www.w3.org/ns/activitystreams#", 6 | "ldp": "http://www.w3.org/ns/ldp#", 7 | "id": "@id", 8 | "type": "@type", 9 | "Accept": "as:Accept", 10 | "Activity": "as:Activity", 11 | "IntransitiveActivity": "as:IntransitiveActivity", 12 | "Add": "as:Add", 13 | "Announce": "as:Announce", 14 | "Application": "as:Application", 15 | "Arrive": "as:Arrive", 16 | "Article": "as:Article", 17 | "Audio": "as:Audio", 18 | "Block": "as:Block", 19 | "Collection": "as:Collection", 20 | "CollectionPage": "as:CollectionPage", 21 | "Relationship": "as:Relationship", 22 | "Create": "as:Create", 23 | "Delete": "as:Delete", 24 | "Dislike": "as:Dislike", 25 | "Document": "as:Document", 26 | "Event": "as:Event", 27 | "Follow": "as:Follow", 28 | "Flag": "as:Flag", 29 | "Group": "as:Group", 30 | "Ignore": "as:Ignore", 31 | "Image": "as:Image", 32 | "Invite": "as:Invite", 33 | "Join": "as:Join", 34 | "Leave": "as:Leave", 35 | "Like": "as:Like", 36 | "Link": "as:Link", 37 | "Mention": "as:Mention", 38 | "Note": "as:Note", 39 | "Object": "as:Object", 40 | "Offer": "as:Offer", 41 | "OrderedCollection": "as:OrderedCollection", 42 | "OrderedCollectionPage": "as:OrderedCollectionPage", 43 | "Organization": "as:Organization", 44 | "Page": "as:Page", 45 | "Person": "as:Person", 46 | "Place": "as:Place", 47 | "Profile": "as:Profile", 48 | "Question": "as:Question", 49 | "Reject": "as:Reject", 50 | "Remove": "as:Remove", 51 | "Service": "as:Service", 52 | "TentativeAccept": "as:TentativeAccept", 53 | "TentativeReject": "as:TentativeReject", 54 | "Tombstone": "as:Tombstone", 55 | "Undo": "as:Undo", 56 | "Update": "as:Update", 57 | "Video": "as:Video", 58 | "View": "as:View", 59 | "Listen": "as:Listen", 60 | "Read": "as:Read", 61 | "Move": "as:Move", 62 | "Travel": "as:Travel", 63 | "IsFollowing": "as:IsFollowing", 64 | "IsFollowedBy": "as:IsFollowedBy", 65 | "IsContact": "as:IsContact", 66 | "IsMember": "as:IsMember", 67 | "subject": { 68 | "@id": "as:subject", 69 | "@type": "@id" 70 | }, 71 | "relationship": { 72 | "@id": "as:relationship", 73 | "@type": "@id" 74 | }, 75 | "actor": { 76 | "@id": "as:actor", 77 | "@type": "@id" 78 | }, 79 | "attributedTo": { 80 | "@id": "as:attributedTo", 81 | "@type": "@id" 82 | }, 83 | "attachment": { 84 | "@id": "as:attachment", 85 | "@type": "@id" 86 | }, 87 | "bcc": { 88 | "@id": "as:bcc", 89 | "@type": "@id" 90 | }, 91 | "bto": { 92 | "@id": "as:bto", 93 | "@type": "@id" 94 | }, 95 | "cc": { 96 | "@id": "as:cc", 97 | "@type": "@id" 98 | }, 99 | "context": { 100 | "@id": "as:context", 101 | "@type": "@id" 102 | }, 103 | "current": { 104 | "@id": "as:current", 105 | "@type": "@id" 106 | }, 107 | "first": { 108 | "@id": "as:first", 109 | "@type": "@id" 110 | }, 111 | "generator": { 112 | "@id": "as:generator", 113 | "@type": "@id" 114 | }, 115 | "icon": { 116 | "@id": "as:icon", 117 | "@type": "@id" 118 | }, 119 | "image": { 120 | "@id": "as:image", 121 | "@type": "@id" 122 | }, 123 | "inReplyTo": { 124 | "@id": "as:inReplyTo", 125 | "@type": "@id" 126 | }, 127 | "items": { 128 | "@id": "as:items", 129 | "@type": "@id" 130 | }, 131 | "instrument": { 132 | "@id": "as:instrument", 133 | "@type": "@id" 134 | }, 135 | "orderedItems": { 136 | "@id": "as:items", 137 | "@type": "@id", 138 | "@container": "@list" 139 | }, 140 | "last": { 141 | "@id": "as:last", 142 | "@type": "@id" 143 | }, 144 | "location": { 145 | "@id": "as:location", 146 | "@type": "@id" 147 | }, 148 | "next": { 149 | "@id": "as:next", 150 | "@type": "@id" 151 | }, 152 | "object": { 153 | "@id": "as:object", 154 | "@type": "@id" 155 | }, 156 | "oneOf": { 157 | "@id": "as:oneOf", 158 | "@type": "@id" 159 | }, 160 | "anyOf": { 161 | "@id": "as:anyOf", 162 | "@type": "@id" 163 | }, 164 | "closed": { 165 | "@id": "as:closed", 166 | "@type": "xsd:dateTime" 167 | }, 168 | "origin": { 169 | "@id": "as:origin", 170 | "@type": "@id" 171 | }, 172 | "accuracy": { 173 | "@id": "as:accuracy", 174 | "@type": "xsd:float" 175 | }, 176 | "prev": { 177 | "@id": "as:prev", 178 | "@type": "@id" 179 | }, 180 | "preview": { 181 | "@id": "as:preview", 182 | "@type": "@id" 183 | }, 184 | "replies": { 185 | "@id": "as:replies", 186 | "@type": "@id" 187 | }, 188 | "result": { 189 | "@id": "as:result", 190 | "@type": "@id" 191 | }, 192 | "audience": { 193 | "@id": "as:audience", 194 | "@type": "@id" 195 | }, 196 | "partOf": { 197 | "@id": "as:partOf", 198 | "@type": "@id" 199 | }, 200 | "tag": { 201 | "@id": "as:tag", 202 | "@type": "@id" 203 | }, 204 | "target": { 205 | "@id": "as:target", 206 | "@type": "@id" 207 | }, 208 | "to": { 209 | "@id": "as:to", 210 | "@type": "@id" 211 | }, 212 | "url": { 213 | "@id": "as:url", 214 | "@type": "@id" 215 | }, 216 | "altitude": { 217 | "@id": "as:altitude", 218 | "@type": "xsd:float" 219 | }, 220 | "content": "as:content", 221 | "contentMap": { 222 | "@id": "as:content", 223 | "@container": "@language" 224 | }, 225 | "name": "as:name", 226 | "nameMap": { 227 | "@id": "as:name", 228 | "@container": "@language" 229 | }, 230 | "duration": { 231 | "@id": "as:duration", 232 | "@type": "xsd:duration" 233 | }, 234 | "endTime": { 235 | "@id": "as:endTime", 236 | "@type": "xsd:dateTime" 237 | }, 238 | "height": { 239 | "@id": "as:height", 240 | "@type": "xsd:nonNegativeInteger" 241 | }, 242 | "href": { 243 | "@id": "as:href", 244 | "@type": "@id" 245 | }, 246 | "hreflang": "as:hreflang", 247 | "latitude": { 248 | "@id": "as:latitude", 249 | "@type": "xsd:float" 250 | }, 251 | "longitude": { 252 | "@id": "as:longitude", 253 | "@type": "xsd:float" 254 | }, 255 | "mediaType": "as:mediaType", 256 | "published": { 257 | "@id": "as:published", 258 | "@type": "xsd:dateTime" 259 | }, 260 | "radius": { 261 | "@id": "as:radius", 262 | "@type": "xsd:float" 263 | }, 264 | "rel": "as:rel", 265 | "startIndex": { 266 | "@id": "as:startIndex", 267 | "@type": "xsd:nonNegativeInteger" 268 | }, 269 | "startTime": { 270 | "@id": "as:startTime", 271 | "@type": "xsd:dateTime" 272 | }, 273 | "summary": "as:summary", 274 | "summaryMap": { 275 | "@id": "as:summary", 276 | "@container": "@language" 277 | }, 278 | "totalItems": { 279 | "@id": "as:totalItems", 280 | "@type": "xsd:nonNegativeInteger" 281 | }, 282 | "units": "as:units", 283 | "updated": { 284 | "@id": "as:updated", 285 | "@type": "xsd:dateTime" 286 | }, 287 | "width": { 288 | "@id": "as:width", 289 | "@type": "xsd:nonNegativeInteger" 290 | }, 291 | "describes": { 292 | "@id": "as:describes", 293 | "@type": "@id" 294 | }, 295 | "formerType": { 296 | "@id": "as:formerType", 297 | "@type": "@id" 298 | }, 299 | "deleted": { 300 | "@id": "as:deleted", 301 | "@type": "xsd:dateTime" 302 | }, 303 | "inbox": { 304 | "@id": "ldp:inbox", 305 | "@type": "@id" 306 | }, 307 | "outbox": { 308 | "@id": "as:outbox", 309 | "@type": "@id" 310 | }, 311 | "following": { 312 | "@id": "as:following", 313 | "@type": "@id" 314 | }, 315 | "followers": { 316 | "@id": "as:followers", 317 | "@type": "@id" 318 | }, 319 | "streams": { 320 | "@id": "as:streams", 321 | "@type": "@id" 322 | }, 323 | "preferredUsername": "as:preferredUsername", 324 | "endpoints": { 325 | "@id": "as:endpoints", 326 | "@type": "@id" 327 | }, 328 | "uploadMedia": { 329 | "@id": "as:uploadMedia", 330 | "@type": "@id" 331 | }, 332 | "proxyUrl": { 333 | "@id": "as:proxyUrl", 334 | "@type": "@id" 335 | }, 336 | "oauthClientAuthorize": "as:oauthClientAuthorize", 337 | "provideClientKey": "as:provideClientKey", 338 | "authorizeClientKey": "as:authorizeClientKey", 339 | "source": "as:source" 340 | } 341 | } -------------------------------------------------------------------------------- /src/distbin-html/about.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "../util"; 2 | import { distbinBodyTemplate } from "./partials"; 3 | 4 | import { IncomingMessage, ServerResponse } from "http"; 5 | import { resolve as urlResolve } from "url"; 6 | 7 | export const createHandler = ({ externalUrl }: { externalUrl: string }) => { 8 | return (req: IncomingMessage, res: ServerResponse) => { 9 | res.writeHead(200, { 10 | "content-type": "text/html", 11 | }); 12 | res.end( 13 | distbinBodyTemplate({ externalUrl })(` 14 | ${createAboutMessage()} 15 | ${createReplySection({ 16 | externalUrl, 17 | inReplyTo: urlResolve(externalUrl, `.${req.url}`), 18 | })} 19 | `), 20 | ); 21 | }; 22 | }; 23 | 24 | function createReplySection({ 25 | inReplyTo, 26 | externalUrl, 27 | }: { 28 | inReplyTo: string; 29 | externalUrl: string; 30 | }) { 31 | return ` 32 | 38 |
39 |
40 | reply 41 |
42 | ${createReplyForm({ inReplyTo, externalUrl })} 43 |
44 | `; 45 | } 46 | 47 | function createReplyForm({ 48 | inReplyTo, 49 | externalUrl, 50 | }: { 51 | inReplyTo: string; 52 | externalUrl: string; 53 | }) { 54 | return ` 55 | 77 |
78 | 79 | 80 | 81 | 82 |
83 | `; 84 | } 85 | 86 | const htmlEntities = { 87 | checked: "☑", 88 | unchecked: "☐", 89 | }; 90 | 91 | function createAboutMessage() { 92 | const msg = ` 93 |

94 | distbin is a distributed 95 | pastebin. 96 | i.e. it is a service where anyone can post things on the web, and others can react 97 | by posting anywhere else on the web (including here). 98 |

99 |

100 | Of course, there are lots of other places you can post things. Most people communicate 101 | online dozens of times per day. But all these places where we post and talk and learn 102 | are isolated. We talk in them, but they don't talk to each other. 103 |

104 |

105 | Because they're isolated, we don't get much choice in how we communicate with our 106 | friends. We react to things wherever we find them. Your contributions to the web are 107 | fragmented, with no easy way to go back and remember or save them. 108 |

109 |

110 | Participating in places we don't choose also has some hidden risks. What if one of 111 | them goes down, gets bought, censored, surveiled, or moderated by policies you don't 112 | agree with? 113 |

114 |

115 | What makes distbin unique is that it supports distributed social interactions. 116 | For example, your reply to a post on this distbin can be hosted by another 117 | distbin. Or your personal blog. Or your corporate intranet. The conversation can 118 | be spread out across the web, instead of siloed in just one place. 119 |

120 |

121 | With a distributed social web, you could have long term ownership of the things 122 | you create online and the way you consume and socialize around them. distbin is 123 | the first tool anyone (especially non-programmers!) can use to communicate in this way. 124 |

125 |

126 | Sound interesting? Post your thoughts below to try it out or come to see the 127 | source. 128 |

129 |
130 | 131 | Technical Info, Planned Features, and Progress 132 | 133 |

134 | Distributed social features are powered by relatively new (2018) web standards from 135 | the W3C Social Web Working Group, 136 | for example the 137 | Activity Streams 138 | vocabulary and Webmention, 139 | ActivityPub, and 140 | Linked Data Notifications protocols. 141 |

142 |
    143 |
  • distbin-api 144 |
      145 |
    • ActivityPub - /activitypub 146 |
        147 |
      • ${htmlEntities.checked} 148 | Outbox 149 | exists and can activities can be POSTed to it - 150 | /activitypub/outbox 151 |
      • 152 |
      • ${htmlEntities.checked} Retrieval of recent items in 153 | Public Collection 154 |
          155 |
        • ${ 156 | htmlEntities.checked 157 | } Respect 'max-member-count' param in 158 | RFC7240 HTTP Prefer 159 | request header (or querystring for URIs)
        • 160 |
        161 |
      • 162 |
      • 163 | ${ 164 | htmlEntities.checked 165 | } when activities are received in the outbox, 166 | 167 | notify/deliver to other targeted servers 168 |
      • 169 |
      • 170 | ${ 171 | htmlEntities.checked 172 | } receive activities from other parts of the web 173 | according to Inbox Delivery 174 |
          175 |
        • ${ 176 | htmlEntities.checked 177 | } Render these related activities on the target's html representation
        • 178 |
        179 |
      • 180 |
      181 |
    • 182 |
    • Micropub 183 |
        184 |
      • ${htmlEntities.unchecked} 185 | Create posts 186 |
      • 187 |
      • 188 | ${htmlEntities.unchecked} 189 | Querying posts and capabilities
      • 190 |
      191 |
    • 192 |
    • 193 | Webmention 194 |
        195 |
      • ${ 196 | htmlEntities.unchecked 197 | } when posts are created that refer to other web resources, 198 | notify those other resources using Webmention
      • 199 |
      • ${ 200 | htmlEntities.unchecked 201 | } Receive/render webmentions when other parts of the web 202 | mention distbin resources
      • 203 |
      204 |
    • 205 |
    • 206 | API Authorization using OAuth2. Everyone is authorized to create a post, 207 | even anonymously, and receive an API token to manage that post. 208 | If a creator wants to authenticate, e.g. for ego or attribution, 209 | support federated authentication using something like 210 | OpenID Connect and/or 211 | accountchooser.net. This has the property 212 | of delegating authentication to providers of the users' choice instead of creating 213 | yet another identity provider. 214 |
    • 215 |
    216 |
  • 217 |
  • 218 | distbin-html - UI to consume and interact with posts in distbin. Hopefully a pure 219 | one-way dependency to distbin-api and thus swappable for other UI preferences 220 |
      221 |
    • ${htmlEntities.checked} This homepage
    • 222 |
    • ${htmlEntities.checked} shareable pages for each activity
    • 223 |
    • ${ 224 | htmlEntities.unchecked 225 | } activities and Posts are different things. 226 | Sometimes activities create posts; sometimes not. Differentiate between how these 227 | are rendered (or defensibly decide not to).
    • 228 |
    229 |
  • 230 |
231 |
232 | `; 233 | return msg; 234 | } 235 | -------------------------------------------------------------------------------- /src/distbin-html/an-activity.ts: -------------------------------------------------------------------------------- 1 | import { ASJsonLdProfileContentType, isASLink } from "../activitystreams"; 2 | import { 3 | Activity, 4 | ASLink, 5 | ASObject, 6 | Collection, 7 | isActivity, 8 | LDObject, 9 | Place, 10 | } from "../types"; 11 | import { 12 | HasLinkPrefetchResult, 13 | LinkPrefetchFailure, 14 | LinkPrefetchResult, 15 | LinkPrefetchSuccess, 16 | } from "../types"; 17 | import { createHttpOrHttpsRequest } from "../util"; 18 | import { debuglog, first } from "../util"; 19 | import { encodeHtmlEntities } from "../util"; 20 | import { isProbablyAbsoluteUrl } from "../util"; 21 | import { readableToString } from "../util"; 22 | import { sendRequest } from "../util"; 23 | import { ensureArray } from "../util"; 24 | import { flatten } from "../util"; 25 | import { distbinBodyTemplate } from "./partials"; 26 | import { everyPageHead } from "./partials"; 27 | import { sanitize } from "./sanitize"; 28 | import { internalUrlRewriter } from "./url-rewriter"; 29 | 30 | import * as fs from 'fs'; 31 | import { highlightAuto } from "highlight.js" 32 | import { IncomingMessage, ServerResponse } from "http"; 33 | import * as marked from "marked"; 34 | import * as url from "url"; 35 | 36 | import { createLogger } from "../logger"; 37 | const logger = createLogger(__filename); 38 | 39 | const failedToFetch = Symbol("is this a Link that distbin failed to fetch?"); 40 | 41 | // Highlighting 42 | const highlightCss = fs.readFileSync(require.resolve('highlight.js/styles/github.css'), 'utf-8'); 43 | marked.setOptions({ 44 | highlight (code) { 45 | return highlightAuto(code).value; 46 | } 47 | }); 48 | 49 | // create handler to to render a single activity to a useful page 50 | export const createHandler = ({ 51 | apiUrl, 52 | activityId, 53 | externalUrl, 54 | internalUrl, 55 | }: { 56 | apiUrl: string; 57 | activityId: string; 58 | externalUrl: string; 59 | internalUrl: string; 60 | }) => { 61 | return async (req: IncomingMessage, res: ServerResponse) => { 62 | const activityUrl = apiUrl + req.url; 63 | const activityRes = await sendRequest( 64 | createHttpOrHttpsRequest(activityUrl), 65 | ); 66 | if (activityRes.statusCode !== 200) { 67 | // proxy 68 | res.writeHead(activityRes.statusCode, activityRes.headers); 69 | activityRes 70 | .pipe( 71 | res, 72 | { end: true }, 73 | ) 74 | .on("finish", res.end); 75 | return; 76 | } 77 | 78 | const incomingActivity = JSON.parse(await readableToString(activityRes)); 79 | const activityWithoutDescendants = activityWithUrlsRelativeTo( 80 | incomingActivity, 81 | externalUrl, 82 | ); 83 | const repliesUrls = ensureArray(activityWithoutDescendants.replies).map( 84 | (repliesUrl: string) => { 85 | return url.resolve(activityUrl, repliesUrl); 86 | }, 87 | ); 88 | const descendants = flatten( 89 | await Promise.all( 90 | repliesUrls.map(repliesUrl => 91 | fetchDescendants( 92 | repliesUrl, 93 | internalUrlRewriter(internalUrl, externalUrl), 94 | ), 95 | ), 96 | ), 97 | ); 98 | const activity = Object.assign(activityWithoutDescendants, { 99 | replies: descendants, 100 | }); 101 | const ancestors = await fetchReplyAncestors( 102 | externalUrl, 103 | activity, 104 | internalUrlRewriter(internalUrl, externalUrl), 105 | ); 106 | 107 | async function fetchDescendants( 108 | repliesUrl: string, 109 | urlRewriter: (u: string) => string, 110 | ) { 111 | const repliesCollectionResponse = await sendRequest( 112 | createHttpOrHttpsRequest(urlRewriter(repliesUrl)), 113 | ); 114 | if (repliesCollectionResponse.statusCode !== 200) { 115 | return { 116 | name: `Failed to fetch replies at ${repliesUrl} (code ${ 117 | repliesCollectionResponse.statusCode 118 | })`, 119 | }; 120 | } 121 | const repliesCollection = JSON.parse( 122 | await readableToString(repliesCollectionResponse), 123 | ); 124 | 125 | if (repliesCollection.totalItems <= 0) { 126 | return repliesCollection; 127 | } 128 | repliesCollection.items = await Promise.all( 129 | repliesCollection.items.map(async (replyActivity: Activity) => { 130 | // activity with resolved .replies collection 131 | const withAbsoluteUrls = activityWithUrlsRelativeTo( 132 | replyActivity, 133 | repliesUrl, 134 | ); 135 | const { replies } = withAbsoluteUrls; 136 | const nextRepliesUrl = 137 | typeof replies === "string" 138 | ? replies 139 | : Array.isArray(replies) && replies.length && replies[0]; 140 | return Object.assign(withAbsoluteUrls, { 141 | replies: 142 | typeof nextRepliesUrl === "string" 143 | ? await fetchDescendants(nextRepliesUrl, urlRewriter) 144 | : replies, 145 | }); 146 | }), 147 | ); 148 | 149 | return repliesCollection; 150 | } 151 | 152 | res.writeHead(200, { 153 | "content-type": "text/html", 154 | }); 155 | res.end(` 156 | 157 | 158 | ${everyPageHead()} 159 | 171 | 172 | 173 | ${distbinBodyTemplate({ externalUrl })(` 174 | ${renderAncestorsSection(ancestors, externalUrl)} 175 | 176 |
177 | ${renderObject(activity, externalUrl)} 178 |
179 | ${renderDescendantsSection( 180 | ensureArray(activity.replies)[0], 181 | externalUrl, 182 | )} 183 | 184 | 207 | `)} 208 | `); 209 | }; 210 | }; 211 | 212 | // todo sandbox .content like 213 | /* 214 | 226 | */ 227 | export const renderActivity = (activity: Activity, externalUrl: string) => 228 | renderObject(activity, externalUrl); 229 | 230 | type URLString = string; 231 | const href = (linkable: URLString | ASLink | ASObject): string => { 232 | if (typeof linkable === "string") { 233 | return linkable; 234 | } 235 | if (isASLink(linkable)) { 236 | return linkable.href; 237 | } 238 | if (linkable.url) { 239 | return href(first(linkable.url)); 240 | } 241 | return; 242 | }; 243 | 244 | export function renderObject(activity: ASObject, externalUrl: string) { 245 | const object = 246 | isActivity(activity) && typeof activity.object === "object" 247 | ? activity.object 248 | : activity; 249 | const published = object.published; 250 | const generator = formatGenerator(activity); 251 | const location = formatLocation(activity); 252 | const attributedTo = formatAttributedTo(activity); 253 | const tags = formatTags(activity); 254 | const activityUrl = ensureArray(activity.url)[0]; 255 | const activityObject = 256 | isActivity(activity) && 257 | ensureArray(activity.object).filter( 258 | (o: ASObject | string) => typeof o === "object", 259 | )[0]; 260 | const mainHtml = (() => { 261 | try { 262 | const maybeMarkdown = activity.content 263 | ? activity.content 264 | : activityObject && 265 | typeof activityObject === "object" && 266 | activityObject.content 267 | ? activityObject.content 268 | : activity.name || activity.url 269 | ? `${activity.url}` 270 | : activity.id 271 | ? `${activity.id}` 272 | : ""; 273 | const html = marked(maybeMarkdown); 274 | const sanitized = sanitize(html); 275 | return sanitized; 276 | } catch (error) { 277 | logger.error("Error rendering activity object.", activity, error); 278 | return `

distbin failed to render this

`; 279 | } 280 | })(); 281 | return ` 282 |
283 |
284 | ${attributedTo || ""} 285 |
286 | ${ 287 | activity.name 288 | ? `

${activity.name}

` 289 | : activityObject && 290 | typeof activityObject === "object" && 291 | activityObject.name 292 | ? `

${activityObject.name}

` 293 | : "" 294 | } 295 |
${mainHtml}
296 | 297 | ${ 298 | tags 299 | ? ` 300 |
301 | ${tags} 302 |
303 | ` 304 | : "" 305 | } 306 | 307 |
308 | ${ensureArray( 309 | isActivity(activity) && 310 | typeof activity.object === "object" && 311 | activity.object.attachment, 312 | ) 313 | .map((attachment: ASObject & HasLinkPrefetchResult) => { 314 | if (!attachment) { 315 | return ""; 316 | } 317 | switch (attachment.type) { 318 | case "Link": 319 | const prefetch: LinkPrefetchResult = 320 | attachment["https://distbin.com/ns/linkPrefetch"]; 321 | if (prefetch.type === "LinkPrefetchFailure") { 322 | return; 323 | } 324 | const linkPrefetchSuccess = prefetch as LinkPrefetchSuccess; 325 | if ( 326 | !( 327 | linkPrefetchSuccess && 328 | linkPrefetchSuccess.supportedMediaTypes 329 | ) 330 | ) { 331 | return ""; 332 | } 333 | if ( 334 | linkPrefetchSuccess.supportedMediaTypes.find((m: string) => 335 | m.startsWith("image/"), 336 | ) 337 | ) { 338 | return ( 339 | linkPrefetchSuccess.link && 340 | ` 341 | 342 | ` 343 | ); 344 | } 345 | break; 346 | default: 347 | break; 348 | } 349 | return ""; 350 | }) 351 | .filter(Boolean) 352 | .join("\n")} 353 |
354 | 355 | ${/* TODO add byline */ ""} 356 | 386 |
387 | 388 | `; 389 | } 390 | 391 | function formatTags(o: ASObject) { 392 | const tags = ensureArray( 393 | (isActivity(o) && typeof o.object === "object" && o.object.tag) || o.tag, 394 | ).filter(Boolean); 395 | return tags 396 | .map(renderTag) 397 | .filter(Boolean) 398 | .join(" "); 399 | function renderTag(tag: ASObject) { 400 | const text = tag.name || tag.id || first(tag.url); 401 | if (!text) { 402 | return; 403 | } 404 | const safeText = encodeHtmlEntities(text); 405 | const tagUrl = 406 | tag.url || tag.id || (isProbablyAbsoluteUrl(text) ? text : ""); 407 | let rendered; 408 | if (tagUrl) { 409 | rendered = `${safeText}`; 412 | } else { 413 | rendered = `${safeText}`; 414 | } 415 | return rendered; 416 | } 417 | } 418 | 419 | function formatAttributedTo(activity: ASObject | Activity) { 420 | const attributedTo = 421 | activity.attributedTo || 422 | (isActivity(activity) && 423 | typeof activity.object === "object" && 424 | activity.object.attributedTo); 425 | if (!attributedTo) { 426 | return; 427 | } 428 | let formatted = ""; 429 | let authorUrl; 430 | if (typeof attributedTo === "string") { 431 | formatted = encodeHtmlEntities(attributedTo); 432 | } else if (typeof attributedTo === "object") { 433 | formatted = encodeHtmlEntities( 434 | attributedTo.name || first(attributedTo.url), 435 | ); 436 | authorUrl = attributedTo.url; 437 | } 438 | if (authorUrl) { 439 | formatted = ``; 442 | } 443 | if (!formatted) { 444 | return; 445 | } 446 | return ` 447 |
${formatted}
448 | `; 449 | } 450 | 451 | function formatLocation(activity: ASObject) { 452 | const location: Place = activity && activity.location; 453 | if (!location) { 454 | return; 455 | } 456 | let imgUrl; 457 | let linkTo; 458 | if (location.latitude && location.longitude) { 459 | imgUrl = [ 460 | `https://maps.googleapis.com/maps/api/staticmap?`, 461 | `center=${location.latitude},${ 462 | location.longitude 463 | }&zoom=11&size=480x300&sensor=false`, 464 | ].join(""); 465 | linkTo = `https://www.openstreetmap.org/search?query=${location.latitude},${ 466 | location.longitude 467 | }`; 468 | } else if (location.name) { 469 | // use name as center, don't specify zoom 470 | imgUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${ 471 | location.name 472 | }&size=480x300&sensor=false`; 473 | linkTo = `https://www.openstreetmap.org/search?query=${location.name}`; 474 | } 475 | const glyph = ` 476 | 479 | 🌍 480 | 481 | `; 482 | if (!imgUrl) { 483 | return glyph; 484 | } 485 | return ` 486 |
487 | 488 | ${glyph} 489 | 490 |
    491 | ${location.latitude ? `
  • latitude: ${location.latitude}
  • ` : ""} 492 | ${location.longitude ? `
  • longitude: ${location.longitude}
  • ` : ""} 493 | ${ 494 | location.altitude 495 | ? `
  • altitude: ${location.altitude}${location.units || "m"}
  • ` 496 | : "" 497 | } 498 | ${ 499 | location.radius 500 | ? `
  • radius: ${location.radius}${location.units || "m"}
  • ` 501 | : "" 502 | } 503 | ${location.accuracy ? `
  • accuracy: ${location.accuracy}%
  • ` : ""} 504 |
505 |
506 | 507 | 508 | 509 |
510 |
511 | `; 512 | } 513 | 514 | function formatGenerator(o: ASObject) { 515 | const object: ASObject = 516 | isActivity(o) && typeof o.object === "object" && o.object; 517 | const generator = object && object.generator; 518 | if (!generator) { 519 | return ""; 520 | } 521 | let generatorText; 522 | if (typeof generator === "object") { 523 | if (generator.name) { 524 | generatorText = generator.name; 525 | } else if (generator.id) { 526 | generatorText = generator.id; 527 | } 528 | let generatorUrl; 529 | if (generator.url) { 530 | generatorUrl = generator.url; 531 | } else if (generator.id) { 532 | generatorUrl = generator.id; 533 | } 534 | if (generatorUrl) { 535 | return `${generatorText}`; 536 | } 537 | } 538 | if (generatorText) { 539 | return generatorText; 540 | } 541 | return ""; 542 | } 543 | 544 | export const createActivityCss = () => { 545 | return ` 546 | .ancestors, 547 | .descendants { 548 | border-left: 1px solid #efefef; 549 | padding-left: 1em; 550 | } 551 | .activity-item main, 552 | .activity-item .activity-attachments, 553 | .activity-item .activity-attributedTo { 554 | margin: 1rem auto; /* intended to be same as

to force same margins even if main content is not a p */ 555 | } 556 | 557 | .activity-item .activity-attributedTo { 558 | font-style: normal; 559 | } 560 | 561 | .activity-item .activity-tag { 562 | display: inline-block; 563 | padding: 0.5em; 564 | border: 1px solid #eee; 565 | } 566 | 567 | .activity-footer-bar { 568 | line-height: 1em; 569 | } 570 | .activity-footer-bar .glyph { 571 | vertical-align: text-bottom; 572 | } 573 | .activity-footer-bar a { 574 | text-decoration: none; 575 | } 576 | .activity-footer-bar details, 577 | .activity-footer-bar details > summary { 578 | display: inline 579 | } 580 | .activity-footer-bar details > summary { 581 | cursor: pointer; 582 | } 583 | .activity-footer-bar details[open] { 584 | display: block; 585 | } 586 | .activity-item .activity-footer-bar, 587 | .activity-item .activity-footer-bar a { 588 | color: rgba(0, 0, 0, 0.3) 589 | } 590 | .activity-item .activity-attachments img { 591 | max-width: 100%; 592 | } 593 | .activity-location-map img { 594 | width: 100%; 595 | } 596 | .action-show-raw pre { 597 | color: initial 598 | } 599 | code { 600 | background-color: rgba(0, 0, 0, 0.05); 601 | display: inline-block; 602 | } 603 | ${highlightCss} 604 | `; 605 | }; 606 | 607 | class ASObjectWithFetchedReplies extends ASObject { 608 | public replies: Collection; 609 | } 610 | 611 | function renderDescendantsSection( 612 | replies: Collection, 613 | externalUrl: string, 614 | ) { 615 | let inner = ""; 616 | if (replies.totalItems === 0) { 617 | return ""; 618 | } 619 | if (!replies.items && replies.name) { 620 | inner = replies.name; 621 | } else if (replies.items.length === 0) { 622 | inner = "uh... totalItems > 0 but no items included. #TODO"; 623 | } else { 624 | inner = replies.items 625 | .map( 626 | (a: ASObjectWithFetchedReplies) => ` 627 | ${renderObject(a, externalUrl)} 628 | ${renderDescendantsSection(a.replies, externalUrl)} 629 | `, 630 | ) 631 | .join(""); 632 | } 633 | return ` 634 |

635 | ${inner} 636 |
637 | `; 638 | } 639 | 640 | // Render a single ancestor activity 641 | function renderAncestor( 642 | ancestor: Activity | LinkPrefetchFailure, 643 | externalUrl: string, 644 | ): string { 645 | if (ancestor.type === "LinkPrefetchFailure") { 646 | const linkFetchFailure = ancestor as LinkPrefetchFailure; 647 | const linkHref = linkFetchFailure.link.href; 648 | // assume its a broken link 649 | return ` 650 |
651 | ${linkHref} (${linkFetchFailure.error || 652 | "couldn't fetch more info"}) 653 |
654 | `; 655 | } 656 | return renderObject(ancestor, externalUrl); 657 | } 658 | 659 | // Render an item and its ancestors for each ancestor in the array. 660 | // This results in a nested structure conducive to indent-styling 661 | function renderAncestorsSection( 662 | ancestors: Array = [], 663 | externalUrl: string, 664 | ): string { 665 | if (!ancestors.length) { 666 | return ""; 667 | } 668 | const [ancestor, ...olderAncestors] = ancestors; 669 | return ` 670 |
671 | ${ 672 | olderAncestors.length 673 | ? renderAncestorsSection(olderAncestors, externalUrl) 674 | : "" 675 | } 676 | ${renderAncestor(ancestor, externalUrl)} 677 |
678 | `; 679 | } 680 | 681 | async function fetchReplyAncestors( 682 | baseUrl: string, 683 | activity: Activity, 684 | urlRewriter: (u: string) => string, 685 | ): Promise> { 686 | const inReplyTo = flatten( 687 | ensureArray(activity.object) 688 | .filter((o: object | string): o is object => typeof o === "object") 689 | .map((o: Activity) => ensureArray(o.inReplyTo)), 690 | )[0]; 691 | const parentUrl = inReplyTo && url.resolve(baseUrl, href(inReplyTo)); 692 | if (!parentUrl) { 693 | return []; 694 | } 695 | let parent: Activity; 696 | try { 697 | parent = activityWithUrlsRelativeTo( 698 | await fetchActivity(urlRewriter(parentUrl)), 699 | parentUrl, 700 | ); 701 | } catch (err) { 702 | switch (err.code) { 703 | case "ECONNREFUSED": 704 | case "ENOTFOUND": 705 | case "ENETUNREACH": 706 | // don't recurse since we can't fetch the parent 707 | return [ 708 | new LinkPrefetchFailure({ 709 | error: err, 710 | link: { 711 | href: parentUrl, 712 | type: "Link", 713 | }, 714 | }), 715 | ]; 716 | } 717 | throw err; 718 | } 719 | // #TODO support limiting at some reasonable amount of depth to avoid too big 720 | const ancestorsOfParent = await fetchReplyAncestors( 721 | baseUrl, 722 | parent, 723 | urlRewriter, 724 | ); 725 | const ancestorsOrFailures = [parent, ...ancestorsOfParent]; 726 | return ancestorsOrFailures; 727 | } 728 | 729 | async function fetchActivity(activityUrl: string) { 730 | const activityUrlOrRedirect = activityUrl; 731 | let activityResponse = await sendRequest( 732 | createHttpOrHttpsRequest( 733 | Object.assign(url.parse(activityUrlOrRedirect), { 734 | headers: { 735 | accept: `${ASJsonLdProfileContentType}, text/html`, 736 | }, 737 | }), 738 | ), 739 | ); 740 | let redirectsLeft = 3; 741 | /* eslint-disable no-labels */ 742 | followRedirects: while (redirectsLeft > 0) { 743 | switch (activityResponse.statusCode) { 744 | case 301: 745 | case 302: 746 | const resolvedUrl = url.resolve( 747 | activityUrl, 748 | ensureArray(activityResponse.headers.location)[0], 749 | ); 750 | activityResponse = await sendRequest( 751 | createHttpOrHttpsRequest( 752 | Object.assign(url.parse(resolvedUrl), { 753 | headers: { 754 | accept: `${ASJsonLdProfileContentType}, text/html`, 755 | }, 756 | }), 757 | ), 758 | ); 759 | redirectsLeft--; 760 | continue followRedirects; 761 | case 406: 762 | // unacceptable. Server doesn't speak a content-type I know. 763 | return { 764 | url: activityUrl, 765 | }; 766 | case 200: 767 | // cool 768 | break followRedirects; 769 | default: 770 | logger.warn( 771 | "unexpected fetchActivity statusCode", 772 | activityResponse.statusCode, 773 | activityUrl, 774 | ); 775 | break followRedirects; 776 | } 777 | } 778 | /* eslint-enable no-labels */ 779 | 780 | const resContentType = activityResponse.headers["content-type"] 781 | ? // strip off params like charset, profile, etc 782 | ensureArray(activityResponse.headers["content-type"])[0] 783 | .split(";")[0] 784 | .toLowerCase() 785 | : undefined; 786 | switch (resContentType) { 787 | case "application/json": 788 | case "application/activity+json": 789 | case "application/ld+json": 790 | const a = JSON.parse(await readableToString(activityResponse)); 791 | // ensure there is a .url value 792 | return Object.assign(a, { 793 | url: a.url || activityUrl, 794 | }); 795 | case "text/html": 796 | // Make an activity-like thing 797 | return { 798 | url: activityUrl, 799 | // TODO parse for .name ? 800 | }; 801 | default: 802 | throw new Error( 803 | "Unexpected fetched activity content-type: " + 804 | resContentType + 805 | " " + 806 | activityUrl + 807 | " ", 808 | ); 809 | } 810 | } 811 | 812 | const isRelativeUrl = (u: string) => u && !url.parse(u).host; 813 | 814 | // given an activity with some URL values as maybe relative URLs, 815 | // return the activity with them made absolute URLs 816 | // TODO: use json-ld logic for this incl e.g. @base 817 | function activityWithUrlsRelativeTo( 818 | activity: Activity, 819 | relativeTo: string, 820 | ): Activity { 821 | interface IUrlUpdates { 822 | replies?: typeof activity.replies; 823 | url?: typeof activity.url; 824 | } 825 | const updates: IUrlUpdates = {}; 826 | const resolveUrl = (baseUrl: string, relativeUrl: string): string => { 827 | // prepend '.' to baseUrl can have subpath and not get dropped 828 | const resolved = url.resolve(baseUrl, `.${relativeUrl}`); 829 | return resolved; 830 | }; 831 | if (activity.replies) { 832 | updates.replies = (replies => { 833 | if (typeof replies === "string" && isRelativeUrl(replies)) { 834 | return resolveUrl(relativeTo, replies); 835 | } 836 | return replies; 837 | })(activity.replies); 838 | } 839 | if (activity.url) { 840 | updates.url = ensureArray(activity.url).map(u => { 841 | if (typeof u === "string" && isRelativeUrl(u)) { 842 | return resolveUrl(relativeTo, u); 843 | } 844 | if (isASLink(u) && isRelativeUrl(u.href)) { 845 | return Object.assign({}, u, { 846 | href: resolveUrl(relativeTo, u.href), 847 | }); 848 | } 849 | return u; 850 | }); 851 | } 852 | const withAbsoluteUrls = Object.assign({}, activity, updates); 853 | return withAbsoluteUrls; 854 | } 855 | 856 | function formatDate(date: Date, relativeTo = new Date()) { 857 | const MONTH_STRINGS = [ 858 | "Jan", 859 | "Feb", 860 | "Mar", 861 | "Apr", 862 | "May", 863 | "Jun", 864 | "Jul", 865 | "Aug", 866 | "Sep", 867 | "Oct", 868 | "Nov", 869 | "Dec", 870 | ]; 871 | const diffMs = date.getTime() - relativeTo.getTime(); 872 | let dateString; 873 | // Future 874 | if (diffMs > 0) { 875 | throw new Error("formatDate cannot format dates in the future"); 876 | } 877 | // Just now (0s) 878 | if (diffMs > -1000) { 879 | return "1s"; 880 | } 881 | // Less than 60s ago -> 5s 882 | if (diffMs > -60 * 1000) { 883 | return Math.round((-1 * diffMs) / 1000) + "s"; 884 | } 885 | // Less than 1h ago -> 5m 886 | if (diffMs > -60 * 60 * 1000) { 887 | return Math.round((-1 * diffMs) / (1000 * 60)) + "m"; 888 | } 889 | // Less than 24h ago -> 5h 890 | if (diffMs > -60 * 60 * 24 * 1000) { 891 | return Math.round((-1 * diffMs) / (1000 * 60 * 60)) + "hrs"; 892 | } 893 | // >= 24h ago -> 6 Jul 894 | dateString = date.getDate() + " " + MONTH_STRINGS[date.getMonth()]; 895 | // or like 6 Jul 2012 if the year if its different than the relativeTo year 896 | if (date.getFullYear() !== relativeTo.getFullYear()) { 897 | dateString += " " + date.getFullYear(); 898 | } 899 | return dateString; 900 | } 901 | -------------------------------------------------------------------------------- /src/distbin-html/home.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import { IncomingMessage, ServerResponse } from "http"; 3 | import * as querystring from "querystring"; 4 | import * as url from "url"; 5 | import * as createUrlRegex from "url-regex"; 6 | import { publicCollectionId } from "../activitypub"; 7 | import { clientAddressedActivity } from "../activitypub"; 8 | import { discoverOutbox } from "../activitypub"; 9 | import { ASJsonLdProfileContentType } from "../activitystreams"; 10 | import { Activity, ASObject } from "../activitystreams"; 11 | import { createLogger } from "../logger"; 12 | import { 13 | ASLink, 14 | HasLinkPrefetchResult, 15 | LinkPrefetchFailure, 16 | LinkPrefetchResult, 17 | LinkPrefetchSuccess, 18 | } from "../types"; 19 | import { requestUrl } from "../util"; 20 | import { isProbablyAbsoluteUrl } from "../util"; 21 | import { createHttpOrHttpsRequest } from "../util"; 22 | import { debuglog, first } from "../util"; 23 | import { encodeHtmlEntities, readableToString, sendRequest } from "../util"; 24 | import { distbinBodyTemplate } from "./partials"; 25 | import { internalUrlRewriter } from "./url-rewriter"; 26 | 27 | const logger = createLogger(__filename); 28 | 29 | export const createHandler = ({ 30 | apiUrl, 31 | externalUrl, 32 | internalUrl, 33 | }: { 34 | apiUrl: string; 35 | externalUrl: string; 36 | internalUrl: string; 37 | }) => { 38 | return async (req: IncomingMessage, res: ServerResponse) => { 39 | switch (req.method.toLowerCase()) { 40 | // POST is form submission to create a new post 41 | case "post": 42 | const submission = await readableToString(req); 43 | // assuming application/x-www-form-urlencoded 44 | const formFields = querystring.parse(submission); 45 | const { attachment } = formFields; 46 | const inReplyTo = first(formFields.inReplyTo); 47 | const firstAttachment = first(attachment); 48 | if (firstAttachment && !isProbablyAbsoluteUrl(firstAttachment)) { 49 | throw new Error( 50 | "attachment must be a URL, but got " + firstAttachment, 51 | ); 52 | } 53 | const attachmentLink = await getAttachmentLinkForUrl(firstAttachment); 54 | 55 | let location; 56 | try { 57 | location = parseLocationFormFields(formFields); 58 | } catch (error) { 59 | logger.error(error); 60 | throw new Error("Error parsing location form fields"); 61 | } 62 | 63 | let attributedTo = {} as any; 64 | if (formFields["attributedTo.name"]) { 65 | attributedTo.name = formFields["attributedTo.name"]; 66 | } 67 | const attributedToUrl = first(formFields["attributedTo.url"]); 68 | if (attributedToUrl) { 69 | if (!isProbablyAbsoluteUrl(attributedToUrl)) { 70 | throw new Error( 71 | "Invalid non-URL value for attributedTo.url: " + attributedToUrl, 72 | ); 73 | } 74 | attributedTo.url = attributedToUrl; 75 | } 76 | if (Object.keys(attributedTo).length === 0) { 77 | attributedTo = undefined; 78 | } 79 | 80 | let tag; 81 | if (formFields.tag_csv) { 82 | tag = first(formFields.tag_csv) 83 | .split(",") 84 | .map((n: string) => { 85 | return { 86 | name: n.trim(), 87 | }; 88 | }); 89 | } 90 | 91 | const note: ASObject = Object.assign( 92 | { 93 | attachment: attachmentLink ? [attachmentLink] : undefined, 94 | content: first(formFields.content), 95 | generator: { 96 | name: "distbin-html", 97 | type: "Application", 98 | url: externalUrl, 99 | // @todo add .url of externalUrl 100 | }, 101 | tag, 102 | type: "Note", 103 | }, 104 | inReplyTo ? { inReplyTo } : {}, 105 | ); 106 | const unaddressedActivity: Activity = { 107 | "@context": "https://www.w3.org/ns/activitystreams", 108 | attributedTo, 109 | cc: [publicCollectionId, inReplyTo].filter(Boolean), 110 | location, 111 | object: note, 112 | type: "Create", 113 | }; 114 | debuglog("about to await clientAddressedActivity", { 115 | unaddressedActivity, 116 | }); 117 | const addressedActivity = await (async () => { 118 | const withClientAddresssing = await clientAddressedActivity( 119 | unaddressedActivity, 120 | 0, 121 | true, 122 | internalUrlRewriter(internalUrl, externalUrl), 123 | ); 124 | return { 125 | ...withClientAddresssing, 126 | bcc: [ 127 | ...(Array.isArray(withClientAddresssing.bcc) 128 | ? withClientAddresssing.bcc 129 | : [withClientAddresssing.bcc].filter(Boolean) 130 | ), 131 | // Add to bcc any absolute URLs in the content 132 | ...(note.content.match(createUrlRegex()) || []), 133 | ] 134 | } 135 | })(); 136 | 137 | debuglog("addressedActivity", addressedActivity); 138 | // submit to outbox 139 | // #TODO discover outbox URL 140 | debuglog("about to discoverOutbox", { apiUrl }); 141 | const outboxUrl = await discoverOutbox(apiUrl); 142 | debuglog("distbin-html/home is posting to outbox", { 143 | apiUrl, 144 | outboxUrl, 145 | }); 146 | const postToOutboxRequest = http.request( 147 | Object.assign( 148 | url.parse(internalUrlRewriter(internalUrl, externalUrl)(outboxUrl)), 149 | { 150 | headers: { 151 | "content-type": ASJsonLdProfileContentType, 152 | }, 153 | method: "post", 154 | }, 155 | ), 156 | ); 157 | postToOutboxRequest.write(JSON.stringify(addressedActivity)); 158 | const postToOutboxResponse = await sendRequest(postToOutboxRequest); 159 | switch (postToOutboxResponse.statusCode) { 160 | case 201: 161 | const postedLocation = postToOutboxResponse.headers.location; 162 | // handle form submission by posting to outbox 163 | res.writeHead(302, { location: postedLocation }); 164 | res.end(); 165 | break; 166 | case 500: 167 | res.writeHead(500); 168 | postToOutboxResponse.pipe(res); 169 | break; 170 | default: 171 | throw new Error("unexpected upstream response"); 172 | } 173 | break; 174 | // GET renders home page will all kinds of stuff 175 | case "get": 176 | const query = url.parse(req.url, true).query; // todo sanitize 177 | const safeInReplyToDefault = encodeHtmlEntities( 178 | first(query.inReplyTo) || "", 179 | ); 180 | const safeContentDefault = encodeHtmlEntities( 181 | first(query.content) || "", 182 | ); 183 | const safeAttributedToNameDefault = encodeHtmlEntities( 184 | first(query['attributedTo.name']) || "", 185 | ); 186 | const safeTitleDefault = encodeHtmlEntities(first(query.title) || ""); 187 | const safeAttachmentUrl = encodeHtmlEntities( 188 | first(query.attachment) || "", 189 | ); 190 | res.writeHead(200, { 191 | "content-type": "text/html", 192 | }); 193 | /* tslint:disable:max-line-length */ 194 | res.write( 195 | distbinBodyTemplate({ externalUrl })(` 196 | ${` 197 | <style> 198 | .post-form textarea { 199 | height: calc(100% - 14em - 8px); /* everything except the rest of this form */ 200 | min-height: 4em; 201 | } 202 | .post-form textarea, 203 | .post-form input, 204 | .post-form-show-more > summary { 205 | border: 0; 206 | font: inherit; 207 | padding: 1em; 208 | margin-bottom: 2px; /* account for webkit :focus glow overflow */ 209 | } 210 | .post-form-stretch { 211 | width: calc(100% + 2em); 212 | margin-left: -1em; 213 | margin-right: -1em; 214 | } 215 | .post-form .post-form-label-with-input { 216 | margin: 1em 0; 217 | } 218 | .post-form-show-more { 219 | } 220 | .post-form input[type=submit]:hover, 221 | .post-form summary { 222 | cursor: pointer; 223 | } 224 | .cursor-pointer:hover { 225 | cursor: pointer; 226 | } 227 | </style> 228 | <script> 229 | window.addGeolocation = function (addLocationEl) { 230 | var currentlyInsertedEl = addLocationEl; 231 | var locationInputGroup = addLocationEl.closest('.post-form-geolocation-input-group') 232 | if ( ! locationInputGroup) { 233 | throw new Error("addGeolocation must be called with an element inside a .post-form-geolocation-input-group") 234 | } 235 | // show loading indicator 236 | var gettingLocationEl = document.createElement('span'); 237 | gettingLocationEl.innerHTML = 'Getting Location...' 238 | addLocationEl.parentNode.replaceChild(gettingLocationEl, addLocationEl) 239 | currentlyInsertedEl = gettingLocationEl 240 | // ok now to request location 241 | navigator.geolocation.getCurrentPosition(success, failure); 242 | function success(position) { 243 | var coords= position.coords || {}; 244 | logger.log('Your position', position) 245 | var coordPropsToFormFields = { 246 | 'altitude': 'location.altitude', 247 | 'latitude': 'location.latitude', 248 | 'longitude': 'location.longitude', 249 | 'accuracy': 'location.radius', 250 | } 251 | var hiddenInputsToCreate = Object.keys(coordPropsToFormFields).map(function (coordProp) { 252 | var coordValue = coords[coordProp] 253 | if ( ! coordValue) return; 254 | var formFieldName = coordPropsToFormFields[coordProp] 255 | return { 256 | name: formFieldName, 257 | value: coordValue, 258 | } 259 | }).filter(Boolean); 260 | if (coords.altitude || coords.accuracy) { 261 | hiddenInputsToCreate.push({ name: 'location.units', value: 'm' }) 262 | } 263 | if (coords.altitude || coords.latitude || coords.longitude) { 264 | hiddenInputsToCreate.push({ name: 'location.accuracy', value: 95.0 }) 265 | } 266 | 267 | // update the form with hidden fields for this info 268 | hiddenInputsToCreate.forEach(insertOrReplaceInput); 269 | 270 | // replace loading indicator with 'undo' 271 | var undoElement = createUndoElement([ 272 | 'Clicking post will save your coordinates', 273 | (coords.latitude && coords.longitude) ? (' ('+coords.latitude+', '+coords.longitude+')') : '', 274 | '. Click here to undo.' 275 | ].join('')) 276 | gettingLocationEl.parentNode.replaceChild(undoElement, currentlyInsertedEl); 277 | currentlyInsertedEl = undoElement 278 | 279 | function createUndoElement(text) { 280 | var undoElement = document.createElement('a'); 281 | undoElement.innerHTML = text; 282 | undoElement.style.cursor = 'pointer'; 283 | undoElement.onclick = function (event) { 284 | // replace with the original addLocationEl that triggered everything 285 | undoElement.parentNode.replaceChild(addLocationEl, undoElement); 286 | currentlyInsertedEl = addLocationEl 287 | } 288 | return undoElement 289 | } 290 | 291 | function insertOrReplaceInput(inputInfo) { 292 | var name = inputInfo.name; 293 | var value = inputInfo.value; 294 | var input = document.createElement('input') 295 | input.type = 'hidden'; 296 | input.value = value; 297 | input.name = name; 298 | var oldInput = locationInputGroup.querySelector('input[name="'+name+'"]') 299 | if (oldInput) { 300 | oldInput.parentNode.replaceChild(input, oldInput); 301 | } else { 302 | // insert 303 | locationInputGroup.appendChild(input) 304 | } 305 | } 306 | 307 | } 308 | function failure(error) { 309 | logger.error("Error getting current position", error) 310 | var failureElement = document.createElement('a'); 311 | failureElement.style.cursor = 'pointer'; 312 | failureElement.innerHTML = ['Error getting geolocation', error.message].filter(Boolean).join(': ') 313 | failureElement.onclick = function (e) { 314 | currentlyInsertedEl.parentNode.replaceChild(addLocationEl, currentlyInsertedEl); 315 | currentlyInsertedEl = addLocationEl 316 | } 317 | currentlyInsertedEl.parentNode.replaceChild(failureElement, currentlyInsertedEl); 318 | currentlyInsertedEl = failureElement 319 | } 320 | } 321 | </script> 322 | <form class="post-form" method="post"> 323 | <input name="name" type="text" placeholder="Title (optional)" value="${safeTitleDefault}" class="post-form-stretch"></input> 324 | <textarea name="content" placeholder="Write anonymously, get feedback" class="post-form-stretch">${safeContentDefault}</textarea> 325 | <input name="inReplyTo" type="text" placeholder="replying to another URL? (optional)" value="${safeInReplyToDefault}" class="post-form-stretch"></input> 326 | <details class="post-form-show-more"> 327 | <summary class="post-form-stretch">More</summary> 328 | <input name="attributedTo.name" type="text" placeholder="What's your name? (optional)" class="post-form-stretch" value="${safeAttributedToNameDefault}"></input> 329 | <input name="attributedTo.url" type="text" placeholder="What's your URL? (optional)" class="post-form-stretch"></input> 330 | <input name="attachment" type="text" placeholder="Attachment URL (optional)" class="post-form-stretch" value="${safeAttachmentUrl}"></input> 331 | <input name="tag_csv" type="text" placeholder="Tags (comma-separated, optional)" class="post-form-stretch"></input> 332 | <div class="post-form-geolocation-input-group"> 333 | <input name="location.name" type="text" placeholder="Where are you?" class="post-form-stretch" /> 334 | <p> 335 | <a onclick="addGeolocation(this)" class="cursor-pointer">Add Your Geolocation</a> 336 | </p> 337 | </div> 338 | </details> 339 | <input type="submit" value="post" class="post-form-stretch" /> 340 | </form> 341 | <script> 342 | (function () { 343 | var contentInput = document.querySelector('.post-form *[name=content]') 344 | contentInput.scrollIntoViewIfNeeded(); 345 | contentInput.focus(); 346 | }()) 347 | </script> 348 | `} 349 | <details> 350 | <summary>or POST via API</summary> 351 | <pre>${encodeHtmlEntities(` 352 | curl -XPOST "${requestUrl(req)}activitypub/outbox" -d @- <<EOF 353 | { 354 | "@context": "https://www.w3.org/ns/activitystreams", 355 | "type": "Note", 356 | "content": "This is a note", 357 | "published": "2015-02-10T15:04:55Z", 358 | "cc": ["${publicCollectionId}"] 359 | } 360 | EOF`)} 361 | </pre> 362 | </details> 363 | `), 364 | ); 365 | /* tslint:enable:max-line-length */ 366 | res.end(); 367 | } 368 | }; 369 | }; 370 | 371 | function parseLocationFormFields(formFields: { 372 | [key: string]: string | string[]; 373 | }) { 374 | interface ILocation { 375 | type: string; 376 | name: string; 377 | units: string; 378 | altitude: number; 379 | latitude: number; 380 | longitude: number; 381 | accuracy: number; 382 | radius: number; 383 | } 384 | const location = { type: "Place" } as ILocation; 385 | const formFieldPrefix = "location."; 386 | const prefixed = (name: string) => `${formFieldPrefix}${name}`; 387 | const floatFieldNames: Array<keyof ILocation> = [ 388 | "latitude", 389 | "longitude", 390 | "altitude", 391 | "accuracy", 392 | "radius", 393 | ]; 394 | if (formFields[prefixed("name")]) { 395 | location.name = first(formFields["location.name"]); 396 | } 397 | if (formFields[prefixed("units")]) { 398 | location.units = first(formFields["location.units"]); 399 | } 400 | floatFieldNames.forEach((k: keyof ILocation) => { 401 | const fieldVal = first(formFields[prefixed(k)]); 402 | if (!fieldVal) { 403 | return; 404 | } 405 | location[k] = parseFloat(fieldVal); 406 | }); 407 | if (Object.keys(location).length === 1) { 408 | // there were no location formFields 409 | return; 410 | } 411 | return location; 412 | } 413 | 414 | async function getAttachmentLinkForUrl(attachment: string) { 415 | const attachmentLink: ASLink & HasLinkPrefetchResult = attachment && { 416 | href: attachment, 417 | type: "Link", 418 | }; 419 | let linkPrefetchResult: LinkPrefetchResult; 420 | if (attachment && attachmentLink) { 421 | // try to request the URL to figure out what kind of media type it responds with 422 | // then we can store a hint to future clients that render it 423 | let connectionError; 424 | let attachmentResponse; 425 | try { 426 | attachmentResponse = await sendRequest( 427 | createHttpOrHttpsRequest(Object.assign(url.parse(attachment))), 428 | ); 429 | } catch (error) { 430 | connectionError = error; 431 | logger.warn("Error prefetching attachment URL " + attachment); 432 | logger.error(error); 433 | } 434 | if (connectionError) { 435 | linkPrefetchResult = new LinkPrefetchFailure({ 436 | error: { 437 | message: connectionError.message, 438 | }, 439 | }); 440 | } else if (attachmentResponse.statusCode === 200) { 441 | const contentType = attachmentResponse.headers["content-type"]; 442 | if (contentType) { 443 | linkPrefetchResult = new LinkPrefetchSuccess({ 444 | published: new Date().toISOString(), 445 | supportedMediaTypes: [contentType], 446 | }); 447 | } 448 | } else { 449 | // no connection error, not 200, must be another 450 | linkPrefetchResult = new LinkPrefetchFailure({ 451 | error: { 452 | status: attachmentResponse.statusCode, 453 | }, 454 | }); 455 | } 456 | attachmentLink["https://distbin.com/ns/linkPrefetch"] = linkPrefetchResult; 457 | } 458 | return attachmentLink; 459 | } 460 | 461 | // function createMoreInfo(req, apiUrl) { 462 | // return ` 463 | // <h2>More Info/Links</h2> 464 | // <p> 465 | // This URL as application/json (<code>curl -H "Accept: application/json" ${requestUrl(req)}</code>) 466 | // </p> 467 | // <pre>${ 468 | // encodeHtmlEntities( 469 | // await readableToString( 470 | // await sendRequest( 471 | // http.request(apiUrl) 472 | // ) 473 | // ) 474 | // ) 475 | // }</pre> 476 | // ` 477 | // } 478 | -------------------------------------------------------------------------------- /src/distbin-html/index.ts: -------------------------------------------------------------------------------- 1 | import { route, RoutePattern, RouteResponderFactory } from "../util"; 2 | 3 | import * as about from "./about"; 4 | import * as anActivity from "./an-activity"; 5 | import * as home from "./home"; 6 | import * as publicSection from "./public"; 7 | 8 | import { IncomingMessage, ServerResponse } from "http"; 9 | 10 | import { createLogger } from "../logger"; 11 | const logger = createLogger(__filename); 12 | 13 | interface IDistbinHtmlHandlerOptions { 14 | apiUrl: string; 15 | externalUrl: string; 16 | internalUrl: string; 17 | } 18 | 19 | export const createHandler = ({ 20 | apiUrl, 21 | externalUrl, 22 | internalUrl, 23 | }: IDistbinHtmlHandlerOptions) => { 24 | const routes = new Map<RoutePattern, RouteResponderFactory>([ 25 | [ 26 | new RegExp("^/$"), 27 | () => home.createHandler({ apiUrl, externalUrl, internalUrl }), 28 | ], 29 | [new RegExp("^/about$"), () => about.createHandler({ externalUrl })], 30 | [ 31 | new RegExp("^/public$"), 32 | () => publicSection.createHandler({ apiUrl, externalUrl }), 33 | ], 34 | [ 35 | new RegExp("^/activities/([^/.]+)$"), 36 | (activityId: string) => 37 | anActivity.createHandler({ 38 | activityId, 39 | apiUrl, 40 | externalUrl, 41 | internalUrl, 42 | }), 43 | ], 44 | ]); 45 | return (req: IncomingMessage, res: ServerResponse) => { 46 | const handler = route(routes, req); 47 | if (!handler) { 48 | res.writeHead(404); 49 | res.end("404 Not Found"); 50 | return; 51 | } 52 | Promise.resolve( 53 | (async () => { 54 | return handler(req, res); 55 | })(), 56 | ).catch(e => { 57 | res.writeHead(500); 58 | logger.error(e, e.stack); 59 | res.end("Error: " + e.stack); 60 | }); 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/distbin-html/partials.ts: -------------------------------------------------------------------------------- 1 | import { resolve as urlResolve } from "url"; 2 | 3 | // HTML fragment that should appear in every page's <head> element 4 | export const everyPageHead = () => ` 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> 6 | <style> 7 | body { 8 | font-family: Georgia, "Times New Roman", serif; 9 | margin: 0 auto; 10 | max-width: 42em; 11 | padding: 1em; 12 | } 13 | .distbin-main { 14 | } 15 | .distbin-above-fold { 16 | height: calc(100vh - 3em); /* magic number; height of header */ 17 | } 18 | pre { 19 | max-width: 100%; 20 | overflow-x: auto; 21 | } 22 | p a { 23 | word-wrap: break-word; 24 | } 25 | p img { 26 | max-width: 100%; 27 | } 28 | </style> 29 | `; 30 | 31 | export const aboveFold = (html: string) => ` 32 | <div class="distbin-above-fold"> 33 | ${html} 34 | </div> 35 | `; 36 | 37 | // wrap page with common body template for distbin-html (e.g. header/footer) 38 | export const distbinBodyTemplate = ({ 39 | externalUrl, 40 | }: { 41 | externalUrl: string; 42 | }) => (page: string) => ` 43 | <head> 44 | ${everyPageHead()} 45 | </head> 46 | ${header({ externalUrl })} 47 | <div class="distbin-main"> 48 | ${page} 49 | </div> 50 | `; 51 | 52 | function header({ externalUrl }: { externalUrl: string }) { 53 | // todo 54 | return ` 55 | <style> 56 | html { 57 | box-sizing: border-box; 58 | } 59 | .distbin-header { 60 | margin-bottom: 1em; 61 | width: 100%; 62 | } 63 | .distbin-header-inner { 64 | display: table; 65 | width: 100%; 66 | } 67 | .distbin-header a { 68 | text-decoration: none; 69 | } 70 | 71 | .distbin-header-section { 72 | display: table-cell; 73 | vertical-align: top; 74 | } 75 | .distbin-header-section.right { 76 | text-align: right; 77 | } 78 | .distbin-header-section.right .distbin-header-item { 79 | margin-left: 0.5em; 80 | } 81 | .distbin-header-item { 82 | } 83 | .distbin-header .distbin-header-item.name { 84 | font-weight: bold 85 | } 86 | </style> 87 | <header class="distbin-header"> 88 | <div class="distbin-header-inner"> 89 | <div class="distbin-header-section left"> 90 | <a href="${externalUrl}" class="distbin-header-item name">distbin</a> 91 | </div> 92 | <div class="distbin-header-section right"> 93 | <a href="${urlResolve( 94 | externalUrl, 95 | "./public", 96 | )}" class="distbin-header-item public">public</a> 97 | <a href="${urlResolve( 98 | externalUrl, 99 | "./about", 100 | )}" class="distbin-header-item about">about</a> 101 | </div> 102 | </div> 103 | </header> 104 | `; 105 | } 106 | -------------------------------------------------------------------------------- /src/distbin-html/public.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from "http"; 2 | import * as querystring from "querystring"; 3 | import * as url from "url"; 4 | import { Activity } from "../types"; 5 | import { sendRequest } from "../util"; 6 | import { encodeHtmlEntities } from "../util"; 7 | import { first } from "../util"; 8 | import { readableToString } from "../util"; 9 | import { requestMaxMemberCount } from "../util"; 10 | import { createHttpOrHttpsRequest } from "../util"; 11 | import { linkToHref } from "../util"; 12 | import { createActivityCss, renderActivity } from "./an-activity"; 13 | import { distbinBodyTemplate } from "./partials"; 14 | 15 | export const createHandler = ({ 16 | apiUrl, 17 | externalUrl, 18 | }: { 19 | apiUrl: string; 20 | externalUrl: string; 21 | }) => { 22 | return async (req: IncomingMessage, res: ServerResponse) => { 23 | res.writeHead(200, { 24 | "content-type": "text/html", 25 | }); 26 | res.end( 27 | distbinBodyTemplate({ externalUrl })(` 28 | ${await createPublicBody(req, { 29 | apiUrl, 30 | externalUrl, 31 | })} 32 | `), 33 | ); 34 | }; 35 | }; 36 | 37 | async function createPublicBody( 38 | req: IncomingMessage, 39 | { apiUrl, externalUrl }: { apiUrl: string; externalUrl: string }, 40 | ) { 41 | const limit = requestMaxMemberCount(req) || 10; 42 | if (typeof limit !== "number") { 43 | throw new Error("max-member-count must be a number"); 44 | } 45 | const query = url.parse(req.url, true).query; 46 | let pageUrl = first(query.page); 47 | let pageMediaType = query.pageMediaType || "application/json"; 48 | if (!pageUrl) { 49 | const publicCollectionUrl = apiUrl + "/activitypub/public"; 50 | const publicCollectionRequest = createHttpOrHttpsRequest( 51 | Object.assign(url.parse(publicCollectionUrl), { 52 | headers: { 53 | Prefer: `return=representation; max-member-count="${limit}"`, 54 | }, 55 | }), 56 | ); 57 | const publicCollection = JSON.parse( 58 | await readableToString(await sendRequest(publicCollectionRequest)), 59 | ); 60 | pageUrl = url.resolve( 61 | publicCollectionUrl, 62 | linkToHref(publicCollection.current), 63 | ); 64 | if (typeof publicCollection.current === "object") { 65 | pageMediaType = publicCollection.current.mediaType || pageMediaType; 66 | } 67 | } 68 | const pageRequest = createHttpOrHttpsRequest( 69 | Object.assign(url.parse(pageUrl), { 70 | headers: { 71 | Accept: pageMediaType, 72 | Prefer: `return=representation; max-member-count="${limit}"`, 73 | }, 74 | }), 75 | ); 76 | const pageResponse = await sendRequest(pageRequest); 77 | const page = JSON.parse(await readableToString(pageResponse)); 78 | const nextQuery = 79 | page.next && 80 | Object.assign({}, url.parse(req.url, true).query, { 81 | page: page.next && url.resolve(pageUrl, linkToHref(page.next)), 82 | }); 83 | const nextUrl = nextQuery && `?${querystring.stringify(nextQuery)}`; 84 | const externalPageUrl = pageUrl.replace(apiUrl, externalUrl); 85 | const msg = ` 86 | <style> 87 | ${createActivityCss()} 88 | </style> 89 | <h2>Public Activity</h2> 90 | <p>Fetched from <a href="${externalPageUrl}">${externalPageUrl}</a></p> 91 | <details> 92 | <summary>{…}</summary> 93 | <pre><code>${encodeHtmlEntities( 94 | // #TODO: discover /public url via HATEOAS 95 | JSON.stringify(page, null, 2), 96 | ) 97 | // linkify values of 'url' property (quotes encode to ") 98 | .replace( 99 | /"url": "(.+?)(?=")"/g, 100 | '"url": "<a href="$1">$1</a>"', 101 | )}</code></pre> 102 | </details> 103 | <div> 104 | ${(page.orderedItems || page.items || []) 105 | .map((activity: Activity) => renderActivity(activity, externalUrl)) 106 | .join("\n")} 107 | </div> 108 | <p> 109 | ${[ 110 | page.startIndex ? `${page.startIndex} previous items` : "", 111 | nextUrl ? `<a href="${nextUrl}">Next Page</a>` : "", 112 | ] 113 | .filter(Boolean) 114 | .join(" - ")} 115 | </p> 116 | `; 117 | return msg; 118 | } 119 | -------------------------------------------------------------------------------- /src/distbin-html/sanitize.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-var-requires 2 | const createDOMPurify = require("dompurify"); 3 | const jsdom = require("jsdom"); 4 | // tslint:enable:no-var-requires 5 | 6 | const window = jsdom.jsdom("", { 7 | features: { 8 | FetchExternalResources: false, // disables resource loading over HTTP / filesystem 9 | ProcessExternalResources: false, // do not execute JS within script blocks 10 | }, 11 | }).defaultView; 12 | 13 | const DOMPurify = createDOMPurify(window); 14 | 15 | export const sanitize = DOMPurify.sanitize.bind(DOMPurify); 16 | 17 | exports.toText = (html: string) => { 18 | return DOMPurify.sanitize(html, { ALLOWED_TAGS: ["#text"] }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/distbin-html/url-rewriter.ts: -------------------------------------------------------------------------------- 1 | import { debuglog } from "../util"; 2 | 3 | export function internalUrlRewriter(internalUrl: string, externalUrl: string) { 4 | debuglog("internalUrlRewriter", { internalUrl, externalUrl }); 5 | if (internalUrl && externalUrl) { 6 | return (urlToRewrite: string) => 7 | urlToRewrite.replace(externalUrl, internalUrl); 8 | } 9 | return (urlToRewrite: string) => urlToRewrite; 10 | } 11 | -------------------------------------------------------------------------------- /src/filemap.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { denodeify } from "./util"; 4 | 5 | import { createLogger } from "../src/logger"; 6 | const logger = createLogger("filemap"); 7 | 8 | const filenameEncoder = { 9 | decode: (filename: string) => { 10 | const pattern = /^data:(.+)?(;base64)?,([^$]*)$/; 11 | const match = filename.match(pattern); 12 | if (!match) { 13 | return filename; 14 | } 15 | const base64encoded = match[3]; 16 | return Buffer.from(base64encoded, "base64").toString(); 17 | }, 18 | encode: (key: string) => { 19 | const base64encoded = Buffer.from(key).toString("base64"); 20 | return `data:base64,${base64encoded}`; 21 | }, 22 | }; 23 | 24 | // TODO: Write tests 25 | 26 | // Like a Map, but keys are files in a dir, and object values are written as file contents 27 | export const JSONFileMap = class<V> extends Map<string, V> { 28 | constructor(private dir: string) { 29 | super(); 30 | } 31 | public ["set"](key: string, val: V) { 32 | const pathForKey = this.keyToExistentPath(key) || this.keyToPath(key); 33 | // coerce to string 34 | const valString = JSON.stringify(val, null, 2); 35 | fs.writeFileSync(pathForKey, valString); 36 | return this; 37 | } 38 | public ["get"](key: string) { 39 | const pathForKey = this.keyToExistentPath(key); 40 | if (!pathForKey) { 41 | return; 42 | } 43 | const fileContents = fs.readFileSync(pathForKey, "utf8"); 44 | return JSON.parse(fileContents); 45 | } 46 | public has(key: string) { 47 | const got = this.get(key); 48 | return Boolean(got); 49 | } 50 | public ["delete"](key: string) { 51 | const pathForKey = this.keyToExistentPath(key); 52 | if (pathForKey) { 53 | fs.unlinkSync(pathForKey); 54 | } 55 | return true; 56 | } 57 | public [Symbol.iterator]() { 58 | return this.entries()[Symbol.iterator](); 59 | } 60 | public keys() { 61 | const dir = this.dir; 62 | const files = fs.readdirSync(dir); 63 | const sortedAscByCreation: string[] = files 64 | .map(name => { 65 | const stat = fs.statSync(path.join(dir, name)); 66 | return { name, stat }; 67 | }) 68 | .sort((a, b) => { 69 | const timeDelta = a.stat.ctime.getTime() - b.stat.ctime.getTime(); 70 | if (timeDelta === 0) { 71 | // fall back to assumption of increasing inodes. I have no idea if 72 | // this is guaranteed, but think it is 73 | // If this is bad, then maybe this whole method should just use 'ls' 74 | // (delegate to the OS) since node isn't good enough here 75 | return a.stat.ino - b.stat.ino; 76 | } 77 | return timeDelta; 78 | }) 79 | .map(({ name }) => this.filenameToKey(name)); 80 | return sortedAscByCreation[Symbol.iterator](); 81 | } 82 | public values() { 83 | return Array.from(this.keys()) 84 | .map((file: string) => this.get(file)) 85 | [Symbol.iterator](); 86 | } 87 | public entries() { 88 | return Array.from(this.keys()) 89 | .map(file => [file, this.get(file)] as [string, V]) 90 | [Symbol.iterator](); 91 | } 92 | private keyToFilename(key: string): string { 93 | if (typeof key !== "string") { 94 | key = JSON.stringify(key); 95 | } 96 | return filenameEncoder.encode(key); 97 | } 98 | private keyToPath(key: string): string { 99 | const keyPath = path.join(this.dir, this.keyToFilename(key)); 100 | return keyPath; 101 | } 102 | private keyToOldPath(key: string): string { 103 | return path.join(this.dir, key); 104 | } 105 | private filenameToKey(filename: string): string { 106 | return filenameEncoder.decode(filename); 107 | } 108 | private keyToExistentPath(key: string): string | void { 109 | for (const pathToTry of [this.keyToPath(key), this.keyToOldPath(key)]) { 110 | if (fs.existsSync(pathToTry)) { 111 | return pathToTry; 112 | } 113 | } 114 | } 115 | get size() { 116 | return Array.from(this.keys()).length; 117 | } 118 | }; 119 | 120 | type AsyncMapKey = string; 121 | type AsyncMapValue = object; 122 | export interface IAsyncMap<K, V> { 123 | size: Promise<number>; 124 | clear(): Promise<void>; 125 | delete(key: K): Promise<boolean>; 126 | forEach( 127 | callbackfn: (value: V, index: K, map: Map<K, V>) => void, 128 | thisArg?: any, 129 | ): void; 130 | get(key: K): Promise<V>; 131 | has(key: K): Promise<boolean>; 132 | set(key: K, value?: V): Promise<IAsyncMap<K, V>>; 133 | // @TODO (ben) these should really be like Iterator<Promise> 134 | entries(): Promise<Iterator<[K, V]>>; 135 | keys(): Promise<Iterator<K>>; 136 | values(): Promise<Iterator<V>>; 137 | } 138 | 139 | // Like a Map, but all methods return a Promise 140 | class AsyncMap<K, V> implements IAsyncMap<K, V> { 141 | public async clear() { 142 | return Map.prototype.clear.call(this); 143 | } 144 | public async delete(key: K) { 145 | return Map.prototype.delete.call(this, key); 146 | } 147 | public forEach(callbackfn: (value: V, index: K, map: Map<K, V>) => void) { 148 | return Map.prototype.forEach.call(this, callbackfn); 149 | } 150 | public async get(key: K) { 151 | return Map.prototype.get.call(this, key); 152 | } 153 | public async has(key: K) { 154 | return Map.prototype.has.call(this, key); 155 | } 156 | public async set(key: K, value: V) { 157 | return Map.prototype.set.call(this, key, value); 158 | } 159 | public async entries() { 160 | return Map.prototype.entries.call(this); 161 | } 162 | public async keys() { 163 | return Map.prototype.keys.call(this); 164 | } 165 | public async values() { 166 | return Map.prototype.values.call(this); 167 | } 168 | get size() { 169 | return (async () => { 170 | return Promise.resolve(Array.from(await this.keys()).length); 171 | })(); 172 | } 173 | } 174 | 175 | // Like JSONFileMap, but all methods return Promises of their values 176 | // and i/o is done async 177 | export class JSONFileMapAsync extends AsyncMap<string, any> 178 | implements IAsyncMap<string, any> { 179 | constructor(private dir: string) { 180 | super(); 181 | } 182 | 183 | public async ["set"](key: string, val: string | object) { 184 | // coerce to string 185 | const pathForKey = this.keyToExistentPath(key) || this.keyToPath(key); 186 | const valString = 187 | typeof val === "string" ? val : JSON.stringify(val, null, 2); 188 | await denodeify(fs.writeFile)(pathForKey, valString); 189 | return this; 190 | } 191 | public async ["get"](key: string) { 192 | const pathForKey = this.keyToExistentPath(key); 193 | if (!pathForKey) { 194 | return; 195 | } 196 | return JSON.parse(await denodeify(fs.readFile)(pathForKey, "utf8")); 197 | } 198 | public async ["delete"](key: string) { 199 | const pathForKey = this.keyToExistentPath(key); 200 | if (pathForKey) { 201 | fs.unlinkSync(pathForKey); 202 | } 203 | return true; 204 | } 205 | public [Symbol.iterator]() { 206 | return this.keys().then(keys => keys[Symbol.iterator]()); 207 | } 208 | public async has(key: string) { 209 | const got = await this.get(key); 210 | return Boolean(got); 211 | } 212 | public async keys() { 213 | const dir = this.dir; 214 | const files = fs.readdirSync(dir); 215 | const sortedAscByCreation = files 216 | .map(name => { 217 | const stat = fs.statSync(path.join(dir, name)); 218 | return { name, stat }; 219 | }) 220 | .sort((a, b) => { 221 | const timeDelta = a.stat.ctime.getTime() - b.stat.ctime.getTime(); 222 | if (timeDelta === 0) { 223 | // fall back to assumption of increasing inodes. I have no idea if 224 | // this is guaranteed, but think it is 225 | // If this is bad, then maybe this whole method should just use 'ls' 226 | // (delegate to the OS) since node isn't good enough here 227 | return a.stat.ino - b.stat.ino; 228 | } 229 | return timeDelta; 230 | }) 231 | .map(({ name }) => filenameEncoder.decode(name)); 232 | return sortedAscByCreation[Symbol.iterator](); 233 | } 234 | public async values() { 235 | const files = await this.keys(); 236 | const values = await Promise.all( 237 | Array.from(files).map(file => this.get(file)), 238 | ); 239 | return values[Symbol.iterator](); 240 | } 241 | public async entries() { 242 | const files = await this.keys(); 243 | const entries = await Promise.all( 244 | Array.from(files).map(async key => { 245 | return [key, await this.get(key)] as [string, any]; 246 | }), 247 | ); 248 | const entriesIterator = entries[Symbol.iterator](); 249 | return entriesIterator; 250 | } 251 | 252 | private keyToFilename(key: string): string { 253 | if (typeof key !== "string") { 254 | key = JSON.stringify(key); 255 | } 256 | return filenameEncoder.encode(key); 257 | } 258 | private keyToPath(key: string): string { 259 | const keyPath = path.join(this.dir, this.keyToFilename(key)); 260 | return keyPath; 261 | } 262 | private keyToOldPath(key: string): string { 263 | return path.join(this.dir, key); 264 | } 265 | private filenameToKey(filename: string): string { 266 | return filenameEncoder.decode(filename); 267 | } 268 | private keyToExistentPath(key: string): string | void { 269 | for (const pathToTry of [this.keyToPath(key), this.keyToOldPath(key)]) { 270 | if (fs.existsSync(pathToTry)) { 271 | return pathToTry; 272 | } 273 | } 274 | } 275 | 276 | get size() { 277 | return Promise.resolve(this.keys()).then(files => Array.from(files).length); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as winston from "winston"; 3 | 4 | const defaultName = "distbin"; 5 | 6 | export const createLogger = function getLogger(name: string) { 7 | const logger = new winston.Logger({ 8 | transports: [ 9 | new winston.transports.Console({ 10 | label: [defaultName, name].filter(Boolean).join("."), 11 | }), 12 | ], 13 | }); 14 | if (process.env.LOG_LEVEL) { 15 | logger.level = process.env.LOG_LEVEL; 16 | } 17 | return logger; 18 | }; 19 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from "http"; 2 | import { 3 | Activity, 4 | ASLink, 5 | ASObject, 6 | Collection, 7 | isActivity, 8 | JSONLD, 9 | LDObject, 10 | LDValue, 11 | LDValues, 12 | Place, 13 | } from "./activitystreams/types"; 14 | export { 15 | Collection, 16 | Place, 17 | LDValue, 18 | LDValues, 19 | LDObject, 20 | JSONLD, 21 | ASLink, 22 | ASObject, 23 | Activity, 24 | isActivity, 25 | }; 26 | 27 | type ISO8601 = string; 28 | 29 | export type HttpRequestResponder = ( 30 | req: IncomingMessage, 31 | res: ServerResponse, 32 | ) => void; 33 | 34 | export type Extendable<T> = T & { 35 | [key: string]: any; 36 | }; 37 | 38 | // extra fields used by distbin 39 | export class DistbinActivity extends Activity { 40 | public "http://www.w3.org/ns/prov#wasDerivedFrom"?: LDValue<object>; 41 | public "distbin:activityPubDeliveryFailures"?: Error[]; 42 | } 43 | export type ActivityMap = Map<string, Activity | DistbinActivity>; 44 | 45 | type mediaType = string; 46 | export class LinkPrefetchResult { 47 | public type: string; 48 | public link: ASLink; 49 | constructor(props: any) { 50 | this.type = this.constructor.name; 51 | Object.assign(this, props); 52 | } 53 | } 54 | export class LinkPrefetchSuccess extends LinkPrefetchResult { 55 | public type: "LinkPrefetchSuccess"; 56 | public published: ISO8601; 57 | public supportedMediaTypes: mediaType[]; 58 | } 59 | export class LinkPrefetchFailure extends LinkPrefetchResult { 60 | public type: "LinkPrefetchFailure"; 61 | public error: { 62 | status?: number; 63 | message: string; 64 | }; 65 | } 66 | export class HasLinkPrefetchResult { 67 | public "https://distbin.com/ns/linkPrefetch"?: LinkPrefetchResult; 68 | } 69 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="node" /> 2 | // tslint:disable:no-var-requires 3 | const jsonldRdfaParser = require("jsonld-rdfa-parser"); 4 | const jsonldLib = require("jsonld"); 5 | // tslint:enable:no-var-requires 6 | jsonldLib.registerRDFParser("text/html", jsonldRdfaParser); 7 | import * as assert from "assert"; 8 | import * as fs from "fs"; 9 | import { ClientRequestArgs } from "http"; 10 | import * as http from "http"; 11 | import * as https from "https"; 12 | import * as path from "path"; 13 | import * as url from "url"; 14 | import { Url, UrlObject } from "url"; 15 | import * as util from "util"; 16 | import { ASLink, HttpRequestResponder } from "./types"; 17 | 18 | import { createLogger } from "../src/logger"; 19 | const logger = createLogger("util"); 20 | 21 | /** 22 | * Return the 'first' item of the provided itemOrList. 23 | * i.e. if itemOrList is an array, return the zero-indexed item. 24 | * if itemOrList is not a collection, return itself 25 | */ 26 | export const first = (itemOrList: any) => { 27 | if (Array.isArray(itemOrList)) { 28 | return itemOrList[0]; 29 | } 30 | return itemOrList; 31 | }; 32 | 33 | export const request = (urlOrOptions: string | UrlObject) => { 34 | const options = 35 | typeof urlOrOptions === "string" ? url.parse(urlOrOptions) : urlOrOptions; 36 | switch (options.protocol) { 37 | case "https:": 38 | return https.request(urlOrOptions); 39 | case "http:": 40 | return http.request(urlOrOptions); 41 | default: 42 | throw new Error(`cannot create request for protocol ${options.protocol}`); 43 | } 44 | }; 45 | 46 | export const debuglog = util.debuglog("distbin"); 47 | 48 | export const readableToString = ( 49 | readable: NodeJS.ReadableStream, 50 | ): Promise<string> => { 51 | let body: string = ""; 52 | return new Promise((resolve, reject) => { 53 | readable.on("error", reject); 54 | readable.on("data", (chunk: string) => { 55 | body += chunk; 56 | return body; 57 | }); 58 | readable.on("end", () => resolve(body)); 59 | }); 60 | }; 61 | 62 | export const requestUrl = (req: http.ServerRequest) => 63 | `http://${req.headers.host}${req.url}`; 64 | 65 | // given a map of strings/regexes to listener factories, 66 | // return a matching route (or undefined if no match) 67 | export type RoutePattern = string | RegExp; 68 | export type RouteResponderFactory = ( 69 | ...matches: string[] 70 | ) => HttpRequestResponder; 71 | export const route = ( 72 | routes: Map<RoutePattern, RouteResponderFactory>, 73 | req: http.ServerRequest, 74 | ) => { 75 | const pathname = url.parse(req.url).pathname; 76 | for (const [routePathname, createHandler] of routes.entries()) { 77 | if (typeof routePathname === "string") { 78 | // exact match 79 | if (pathname !== routePathname) { 80 | continue; 81 | } 82 | return createHandler(); 83 | } 84 | if (routePathname instanceof RegExp) { 85 | const match = pathname.match(routePathname); 86 | if (!match) { 87 | continue; 88 | } 89 | return createHandler(...match.slice(1)); 90 | } 91 | } 92 | }; 93 | 94 | export const sendRequest = ( 95 | r: http.ClientRequest, 96 | ): Promise<http.IncomingMessage> => { 97 | return new Promise((resolve, reject) => { 98 | r.once("response", resolve); 99 | r.once("error", reject); 100 | r.end(); 101 | }); 102 | }; 103 | 104 | export async function followRedirects( 105 | requestOpts: ClientRequestArgs, 106 | maxRedirects = 5, 107 | ) { 108 | let redirectsLeft = maxRedirects; 109 | const initialUrl = url.format(requestOpts); 110 | const latestUrl = initialUrl; 111 | assert(latestUrl); 112 | logger.silly("followRedirects", latestUrl); 113 | 114 | let latestResponse = await sendRequest(request(requestOpts)); 115 | /* eslint-disable no-labels */ 116 | followRedirects: while (redirectsLeft > 0) { 117 | logger.debug("followRedirects got response", { 118 | statusCode: latestResponse.statusCode, 119 | }); 120 | switch (latestResponse.statusCode) { 121 | case 301: 122 | case 302: 123 | const nextUrl = url.resolve( 124 | latestUrl, 125 | ensureArray(latestResponse.headers.location)[0], 126 | ); 127 | logger.debug("followRedirects is following to", nextUrl); 128 | latestResponse = await sendRequest( 129 | request( 130 | Object.assign(url.parse(nextUrl), { 131 | headers: requestOpts.headers, 132 | }), 133 | ), 134 | ); 135 | redirectsLeft--; 136 | continue followRedirects; 137 | default: 138 | return latestResponse; 139 | } 140 | } 141 | throw Object.assign( 142 | new Error(`Max redirects reached when requesting ${initialUrl}`), 143 | { 144 | redirects: maxRedirects - redirectsLeft, 145 | response: latestResponse, 146 | }, 147 | ); 148 | } 149 | 150 | const SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; 151 | // Match everything outside of normal chars and " (quote character) 152 | const NON_ALPHANUMERIC_REGEXP = /([^#-~| |!])/g; 153 | /** 154 | * Escapes all potentially dangerous characters, so that the 155 | * resulting string can be safely inserted into attribute or 156 | * element text. 157 | * @param value 158 | * @returns {string} escaped text 159 | */ 160 | export const encodeHtmlEntities = function encodeEntities(value: string) { 161 | return value 162 | .replace(/&/g, "&") 163 | .replace(SURROGATE_PAIR_REGEXP, match => { 164 | const hi = match.charCodeAt(0); 165 | const low = match.charCodeAt(1); 166 | return "&#" + ((hi - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000) + ";"; 167 | }) 168 | .replace(NON_ALPHANUMERIC_REGEXP, match => { 169 | return "&#" + match.charCodeAt(0) + ";"; 170 | }) 171 | .replace(/</g, "<") 172 | .replace(/>/g, ">"); 173 | }; 174 | 175 | // given a function that accepts a "node-style" errback as its last argument, return 176 | // a function that returns a promise instead 177 | export const denodeify = util.promisify; 178 | 179 | export const rdfaToJsonLd = async (html: string) => { 180 | return denodeify(jsonldLib.fromRDF)(html, { format: "text/html" }); 181 | // // use it 182 | // jsonldLib.fromRDF(html, {format: 'text/html'}, function(err, data) { 183 | }; 184 | 185 | export const isProbablyAbsoluteUrl = (someUrl: string): boolean => { 186 | const absoluteUrlPattern = new RegExp("^(?:[a-z]+:)?//", "i"); 187 | return absoluteUrlPattern.test(someUrl); 188 | }; 189 | 190 | export const ensureArray = <T>(itemOrItems: T | T[]): T[] => 191 | itemOrItems instanceof Array ? itemOrItems : [itemOrItems]; 192 | 193 | export const flatten = <T>(listOfLists: T[][]): T[] => 194 | listOfLists.reduce((flattened, list: T[]) => flattened.concat(list), []); 195 | 196 | // given an http request, return a number that is the maximum number of results this client wants in this response 197 | export const requestMaxMemberCount = (req: http.ServerRequest) => { 198 | const headerMatch = ensureArray(req.headers.prefer) 199 | .filter(Boolean) 200 | .map(header => header.match(/max-member-count="(\d+)"/)) 201 | .filter(Boolean)[0]; 202 | if (headerMatch) { 203 | return parseInt(headerMatch[1], 10); 204 | } 205 | // check querystring 206 | return parseInt( 207 | first(url.parse(req.url, true).query["max-member-count"]), 208 | 10, 209 | ); 210 | }; 211 | 212 | export const createHttpOrHttpsRequest = (urlOrObj: string | UrlObject) => { 213 | const parsedUrl: UrlObject = 214 | typeof urlOrObj === "string" ? url.parse(urlOrObj) : urlOrObj; 215 | let createRequest; 216 | switch (parsedUrl.protocol) { 217 | case "https:": 218 | createRequest = https.request.bind(https); 219 | break; 220 | case "http:": 221 | createRequest = http.request.bind(http); 222 | break; 223 | default: 224 | const activityUrl = url.format(parsedUrl); 225 | throw new Error( 226 | "Can't fetch activity with unsupported protocol in URL (only http, https supported): " + 227 | activityUrl, 228 | ); 229 | } 230 | return createRequest(urlOrObj); 231 | }; 232 | 233 | // given a Link object or url string, return an href string that can be used to refer to it 234 | export const linkToHref = (hrefOrLinkObj: ASLink | string) => { 235 | if (typeof hrefOrLinkObj === "string") { 236 | return hrefOrLinkObj; 237 | } 238 | if (typeof hrefOrLinkObj === "object") { 239 | return hrefOrLinkObj.href; 240 | } 241 | throw new Error("Unexpected link type: " + typeof hrefOrLinkObj); 242 | }; 243 | 244 | jsonldLib.documentLoader = createCustomDocumentLoader(); 245 | 246 | export const jsonld = jsonldLib.promises; 247 | 248 | type Errback = (err: Error, ...args: any[]) => void; 249 | 250 | function createCustomDocumentLoader() { 251 | // define a mapping of context URL => context doc 252 | const CONTEXTS: { [key: string]: string } = { 253 | "https://www.w3.org/ns/activitystreams": fs.readFileSync( 254 | path.join(__dirname, "/as2context.json"), 255 | "utf8", 256 | ), 257 | }; 258 | 259 | // grab the built-in node.js doc loader 260 | const nodeDocumentLoader = jsonldLib.documentLoaders.node(); 261 | // or grab the XHR one: jsonldLib.documentLoaders.xhr() 262 | // or grab the jquery one: jsonldLib.documentLoaders.jquery() 263 | 264 | // change the default document loader using the callback API 265 | // (you can also do this using the promise-based API, return a promise instead 266 | // of using a callback) 267 | const customLoader = (someUrl: string, callback: Errback) => { 268 | if (someUrl in CONTEXTS) { 269 | return callback(null, { 270 | contextUrl: null, // this is for a context via a link header 271 | document: CONTEXTS[someUrl], // this is the actual document that was loaded 272 | documentUrl: someUrl, // this is the actual context URL after redirects 273 | }); 274 | } 275 | // call the underlining documentLoader using the callback API. 276 | nodeDocumentLoader(someUrl, callback); 277 | /* Note: By default, the node.js document loader uses a callback, but 278 | browser-based document loaders (xhr or jquery) return promises if they 279 | are supported (or polyfilled) in the browser. This behavior can be 280 | controlled with the 'usePromise' option when constructing the document 281 | loader. For example: jsonldLib.documentLoaders.xhr({usePromise: false}); */ 282 | }; 283 | return customLoader; 284 | } 285 | 286 | export function assertNever(x: never): never { 287 | throw new Error("Unexpected object: " + x); 288 | } 289 | 290 | // Return new value for a JSON-LD object's value, appending to any existing one 291 | export function jsonldAppend(oldVal: any, valToAppend: any[] | any) { 292 | valToAppend = Array.isArray(valToAppend) ? valToAppend : [valToAppend]; 293 | let newVal; 294 | switch (typeof oldVal) { 295 | case "object": 296 | if (Array.isArray(oldVal)) { 297 | newVal = oldVal.concat(valToAppend); 298 | } else { 299 | newVal = [oldVal, ...valToAppend]; 300 | } 301 | break; 302 | case "undefined": 303 | newVal = valToAppend; 304 | break; 305 | default: 306 | newVal = [oldVal, ...valToAppend]; 307 | break; 308 | } 309 | return newVal; 310 | } 311 | 312 | export const makeErrorClass = ( 313 | name: string, 314 | setUp?: (...args: any[]) => void, 315 | ) => 316 | class extends Error { 317 | constructor(msg: string, ...args: any[]) { 318 | super(msg); 319 | this.message = msg; 320 | this.name = name; 321 | if (typeof setUp === "function") { 322 | setUp.apply(this, arguments); 323 | } 324 | } 325 | }; 326 | -------------------------------------------------------------------------------- /test/activitystreams/index.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | import { testCli } from "../" 3 | import * as AS2 from "../../src/activitystreams" 4 | import { Activity, ASObject, Collection, Note, Place } from "../../src/activitystreams/types" 5 | import { Extendable } from "../../src/types" 6 | 7 | const tests = module.exports 8 | 9 | tests["has types"] = () => { 10 | const example1: Activity = { 11 | "@context": "https://www.w3.org/ns/activitystreams", 12 | "content": "My dog has fleas.", 13 | "summary": "A note", 14 | "type": "Note", 15 | } 16 | const example2: Extendable<Activity> = { 17 | "@context": { 18 | "@language": "en", 19 | "@vocab": "https://www.w3.org/ns/activitystreams", 20 | "ext": "https://canine-extension.example/terms/", 21 | }, 22 | "content": "My dog has fleas.", 23 | "ext:nose": 0, 24 | "ext:smell": "terrible", 25 | "summary": "A note", 26 | "type": "Note", 27 | } 28 | const example3: Extendable<Activity> = { 29 | "@context": [ 30 | "https://www.w3.org/ns/activitystreams", 31 | { 32 | css: "http://www.w3.org/ns/oa#styledBy", 33 | }, 34 | ], 35 | "content": "My dog has fleas.", 36 | "css": "http://www.csszengarden.com/217/217.css?v=8may2013", 37 | "summary": "A note", 38 | "type": "Note", 39 | } 40 | const example4: Activity = { 41 | "@context": "https://www.w3.org/ns/activitystreams", 42 | "actor": "http://www.test.example/martin", 43 | "object": "http://example.org/foo.jpg", 44 | "summary": "Martin created an image", 45 | "type": "Create", 46 | } 47 | const example5: Activity = { 48 | "@context": "https://www.w3.org/ns/activitystreams", 49 | "actor": { 50 | id: "http://www.test.example/martin", 51 | image: { 52 | href: "http://example.org/martin/image.jpg", 53 | mediaType: "image/jpeg", 54 | type: "Link", 55 | }, 56 | name: "Martin Smith", 57 | type: "Person", 58 | url: "http://example.org/martin", 59 | }, 60 | "object": { 61 | id: "http://www.test.example/blog/abc123/xyz", 62 | name: "Why I love Activity Streams", 63 | type: "Article", 64 | url: "http://example.org/blog/2011/02/entry", 65 | }, 66 | "published": "2015-02-10T15:04:55Z", 67 | "summary": "Martin added an article to his blog", 68 | "target": { 69 | id: "http://example.org/blog/", 70 | name: "Martin's Blog", 71 | type: "OrderedCollection", 72 | }, 73 | "type": "Add", 74 | } 75 | const example6: Collection<Activity> = { 76 | "@context": "https://www.w3.org/ns/activitystreams", 77 | "items": [ 78 | { 79 | actor: { 80 | id: "http://www.test.example/martin", 81 | image: { 82 | height: 250, 83 | href: "http://example.org/martin/image", 84 | mediaType: "image/jpeg", 85 | type: "Link", 86 | width: 250, 87 | }, 88 | name: "Martin Smith", 89 | type: "Person", 90 | url: "http://example.org/martin", 91 | }, 92 | generator: "http://example.org/activities-app", 93 | nameMap: { 94 | en: "Martin added a new image to his album.", 95 | ga: "Martin phost le fisean nua a albam.", 96 | }, 97 | object: { 98 | id: "http://example.org/album/máiréad.jpg", 99 | name: "My fluffy cat", 100 | preview: { 101 | href: "http://example.org/album/máiréad.jpg", 102 | mediaType: "image/jpeg", 103 | type: "Link", 104 | }, 105 | type: "Image", 106 | url: [ 107 | { 108 | href: "http://example.org/album/máiréad.jpg", 109 | mediaType: "image/jpeg", 110 | type: "Link", 111 | }, 112 | { 113 | href: "http://example.org/album/máiréad.png", 114 | mediaType: "image/png", 115 | type: "Link", 116 | }, 117 | ], 118 | }, 119 | published: "2011-02-10T15:04:55Z", 120 | target: { 121 | id: "http://example.org/album/", 122 | image: { 123 | href: "http://example.org/album/thumbnail.jpg", 124 | mediaType: "image/jpeg", 125 | type: "Link", 126 | }, 127 | nameMap: { 128 | en: "Martin's Photo Album", 129 | ga: "Grianghraif Mairtin", 130 | }, 131 | type: "Collection", 132 | }, 133 | type: "Add", 134 | }, 135 | ], 136 | "summary": "Martin's recent activities", 137 | "totalItems": 1, 138 | "type": "Collection", 139 | } 140 | const example7: Activity = { 141 | "@context": "https://www.w3.org/ns/activitystreams", 142 | "attributedTo": { 143 | id: "http://joe.website.example/", 144 | name: "Joe Smith", 145 | type: "Person", 146 | }, 147 | "id": "http://example.org/foo", 148 | "name": "My favourite stew recipe", 149 | "published": "2014-08-21T12:34:56Z", 150 | "type": "Note", 151 | } 152 | const example8: Extendable<Place> = { 153 | "@context": [ 154 | "https://www.w3.org/ns/activitystreams", 155 | { 156 | gr: "http://purl.org/goodrelations/v1#", 157 | }, 158 | ], 159 | "gr:category": "restaurants/french_restaurants", 160 | "latitude": 56.78, 161 | "longitude": 12.34, 162 | "name": "Sally's Restaurant", 163 | "type": ["Place", "gr:Location"], 164 | } 165 | const example9: Note = { 166 | "@context": "https://www.w3.org/ns/activitystreams", 167 | "content": "I feel that the weather is appropriate to our season and location.", 168 | "id": "http://example.org/note/123", 169 | "name": "Our Weather Is Fine", 170 | "type": "Note", 171 | } 172 | const example10: Note = { 173 | "@context": "https://www.w3.org/ns/activitystreams", 174 | "content": "Everything is OK here.", 175 | "id": "http://example.org/note/124", 176 | "summary": "A note by Sally", 177 | "type": "Note", 178 | } 179 | const example11: ASObject = { 180 | "@context": "https://www.w3.org/ns/activitystreams", 181 | "id": "http://example.org/application/123", 182 | "image": "http://example.org/application/123.png", 183 | "name": "Exampletron 3000", 184 | "type": "Application", 185 | } 186 | const example12: ASObject = { 187 | "@context": "https://www.w3.org/ns/activitystreams", 188 | "id": "http://example.org/application/123", 189 | "image": { 190 | href: "http://example.org/application/123.png", 191 | mediaType: "image/png", 192 | type: "Link", 193 | }, 194 | "name": "Exampletron 3000", 195 | "type": "Application", 196 | } 197 | const example13: ASObject = { 198 | "@context": "https://www.w3.org/ns/activitystreams", 199 | "id": "http://example.org/application/123", 200 | "image": [ 201 | "http://example.org/application/abc.gif", 202 | { 203 | href: "http://example.org/application/123.png", 204 | mediaType: "image/png", 205 | type: "Link", 206 | }, 207 | ], 208 | "name": "Exampletron 3000", 209 | "type": "Application", 210 | } 211 | const example14: ASObject = { 212 | "@context": "https://www.w3.org/ns/activitystreams", 213 | "id": "http://example.org/application/123", 214 | "image": [ 215 | "http://example.org/application/abc.gif", 216 | { 217 | href: "http://example.org/application/123.png", 218 | mediaType: "image/png", 219 | rel: "thumbnail", 220 | type: "Link", 221 | }, 222 | ], 223 | "name": "Exampletron 3000", 224 | "type": "Application", 225 | } 226 | const example15: Extendable<Activity> = { 227 | "@context": [ 228 | "https://www.w3.org/ns/activitystreams", 229 | {vcard: "http://www.w3.org/2006/vcard/ns#"}, 230 | ], 231 | "actor": { 232 | "id": "http://sally.example.org", 233 | "name": "Sally Smith", 234 | "type": ["Person", "vcard:Individual"], 235 | "vcard:family-name": "Smith", 236 | "vcard:given-name": "Sally", 237 | }, 238 | "object": { 239 | content: "This is a simple note", 240 | type: "Note", 241 | }, 242 | "summary": "Sally created a note", 243 | "type": "Create", 244 | } 245 | const example16: Activity = { 246 | "@context": "https://www.w3.org/ns/activitystreams", 247 | "actor": "http://example.org/profiles/joe", 248 | "id": "http://www.test.example/activity/1", 249 | "object": "http://example.com/notes/1", 250 | "published": "2014-09-30T12:34:56Z", 251 | "summary": "Joe liked a note", 252 | "type": "Like", 253 | } 254 | const example17: Activity = { 255 | "@context": "https://www.w3.org/ns/activitystreams", 256 | "actor": "http://example.org/profiles/joe", 257 | "id": "http://www.test.example/activity/1", 258 | "object": "http://example.com/notes/1", 259 | "published": "2014-09-30T12:34:56Z", 260 | "summary": "Joe liked a note", 261 | "type": ["Like", "http://schema.org/LikeAction"], 262 | } 263 | 264 | const example32: Collection<Extendable<Activity>> = { 265 | "@context": [ 266 | "https://www.w3.org/ns/activitystreams", 267 | { 268 | "dcterms": "http://purl.org/dc/terms/", 269 | "dcterms:created": { 270 | "@id": "dcterms:created", 271 | "@type": "xsd:dateTime", 272 | }, 273 | "oa": "http://www.w3.org/ns/oa#", 274 | "prov": "http://www.w3.org/ns/prov#", 275 | }, 276 | ], 277 | "items": [ 278 | { 279 | actor: { 280 | id: "http://example.org/#eric", 281 | name: "Eric", 282 | }, 283 | id: "http://example.org/activity/20150101000000", 284 | object: { 285 | attributedTo: "http://example.org/#eric", 286 | content: "Remember... all I'm offering is the trooth. Nothing more.", 287 | id: "http://example.org/entry/20150101000000", 288 | type: [ "Note", "prov:Entity" ], 289 | }, 290 | published: "2015-01-01T00:00:00Z", 291 | summary: "Eric wrote a note.", 292 | type: [ "Create", "prov:Activity" ], 293 | }, 294 | { 295 | "dcterms:created": "2015-01-01T00:00:59Z", 296 | "dcterms:creator": { "@id": "http://example.org/#eric" }, 297 | "id": "http://example.org/activity/20150101000059", 298 | "oa:hasBody": { 299 | "content": "Remember... all I'm offering is the truth. Nothing more.", 300 | "id": "http://example.org/entry/20150101000059", 301 | "prov:wasAttributedTo": { "@id": "http://example.org/#eric" }, 302 | "prov:wasRevisionOf": { "@id": "http://example.org/entry/20150101000000" }, 303 | "type": [ "Note", "prov:Entity" ], 304 | }, 305 | "oa:hasTarget": { "@id": "http://example.org/entry/20150101000000" }, 306 | "oa:motivatedBy": { "@id": "oa:editing" }, 307 | "prov:generated": { "@id": "http://example.org/entry/20150101000059" }, 308 | "prov:wasInformedBy": { "@id": "http://example.org/activity/20150101000000" }, 309 | "summary": "Eric edited a note.", 310 | "type": [ "Update", "prov:Activity", "oa:Annotation" ], 311 | }, 312 | { 313 | actor: "http://example.org/#eric", 314 | id: "http://example.org/activity/20150101010101", 315 | object: "http://example.org/entry/20150101000059", 316 | published: "2015-01-01T01:01:01Z", 317 | summary: "Eric deleted a note.", 318 | type: [ "Delete", "prov:Activity" ], 319 | }, 320 | ], 321 | "summary": "Editing history of a note", 322 | "type": "Collection", 323 | 324 | } 325 | } 326 | 327 | if (require.main === module) { 328 | testCli(tests) 329 | } 330 | -------------------------------------------------------------------------------- /test/distbin-html/index.js: -------------------------------------------------------------------------------- 1 | import distbin from '../../' 2 | const { listen } = require('../util') 3 | const url = require('url') 4 | const querystring = require('querystring') 5 | const { readableToString } = require('../../src/util') 6 | const { sendRequest } = require('../../src/util') 7 | const http = require('http') 8 | const assert = require('assert') 9 | const sanitize = require('../../src/distbin-html/sanitize') 10 | 11 | import { testCli } from '../' 12 | 13 | const distbinHtml = require('../../src/distbin-html') 14 | let tests = module.exports 15 | 16 | tests['/ serves html'] = async function () { 17 | const dh = distbinHtml.createHandler({ 18 | apiUrl: 'badurl', 19 | externalUrl: 'badurl' 20 | }) 21 | const dhUrl = await listen(http.createServer(dh)) 22 | const dhResponse = await sendRequest(http.request(Object.assign(url.parse(dhUrl), { 23 | headers: { 24 | accept: 'text/html' 25 | } 26 | }))) 27 | assert.equal(dhResponse.statusCode, 200) 28 | assert.equal(dhResponse.headers['content-type'], 'text/html') 29 | } 30 | 31 | tests['POST / creates activities'] = async function () { 32 | const dbUrl = await listen(http.createServer(distbin())) 33 | const dh = distbinHtml.createHandler({ 34 | apiUrl: dbUrl, 35 | externalUrl: 'badurl' 36 | }) 37 | const dhUrl = await listen(http.createServer(dh)) 38 | const postFormRequest = http.request(Object.assign(url.parse(dhUrl), { 39 | method: 'POST', 40 | headers: { 41 | 'content-type': 'application/x-www-form-urlencoded' 42 | } 43 | })) 44 | postFormRequest.write(querystring.stringify({ 45 | name: 'activity name', 46 | content: 'lorem ipsum', 47 | attachment: dbUrl 48 | })) 49 | const dhResponse = await sendRequest(postFormRequest) 50 | assert.equal(dhResponse.statusCode, 302) 51 | assert(dhResponse.headers.location) 52 | // Ensure a generator was set 53 | // Note: getting from distbin, not distbin-html. 54 | const postedActivityUrl = url.resolve(dbUrl, dhResponse.headers.location) 55 | const activityResponse = await sendRequest(http.request(Object.assign(url.parse(postedActivityUrl), { 56 | headers: { 57 | accept: 'application/json' 58 | } 59 | }))) 60 | const activity = JSON.parse(await readableToString(activityResponse)) 61 | assert(activity.object.generator, 'distbin-html form submission sets distbin-html as the .generator') 62 | assert.equal(Array.isArray(activity.object.attachment), true, '.attachment is an Array') 63 | assert.equal(activity.object.attachment.length, 1, '.attachment[] is there and has the attachment link') 64 | const attachmentLink = activity.object.attachment[0] 65 | assert.equal(attachmentLink.href, dbUrl) 66 | const linkPrefetch = attachmentLink['https://distbin.com/ns/linkPrefetch'] 67 | assert.equal(typeof linkPrefetch.published, 'string', 'linkPrefetch.published is a string') 68 | assert.equal(linkPrefetch.supportedMediaTypes[0], 'application/json', 'linkPrefetch.supportedMediaTypes[0] is the right media type') 69 | } 70 | 71 | tests['POST / can create activities with geolocation'] = async function () { 72 | const dbUrl = await listen(http.createServer(distbin())) 73 | const dh = distbinHtml.createHandler({ 74 | apiUrl: dbUrl, 75 | externalUrl: 'badurl' 76 | }) 77 | const dhUrl = await listen(http.createServer(dh)) 78 | const formFields = { 79 | content: 'lorem ipsum', 80 | 'location.name': 'Penang, Malaysia', 81 | 'location.latitude': 5.365458, 82 | 'location.longitude': 100.45900909999999, 83 | 'location.altitude': 56.1, 84 | 'location.accuracy': 95.0, 85 | 'location.radius': 18408, 86 | 'location.units': 'm' 87 | } 88 | const activity = await postDistbinHtmlActivityForm(dbUrl, dhUrl, formFields) 89 | assert.equal(typeof activity.location, 'object') 90 | assert.equal(activity.location.name, formFields['location.name']) 91 | assert.equal(activity.location.latitude, formFields['location.latitude']) 92 | assert.equal(activity.location.longitude, formFields['location.longitude']) 93 | assert.equal(activity.location.altitude, formFields['location.altitude']) 94 | assert.equal(activity.location.accuracy, formFields['location.accuracy']) 95 | assert.equal(activity.location.radius, formFields['location.radius']) 96 | assert.equal(activity.location.units, formFields['location.units']) 97 | } 98 | 99 | tests['POST / can create activities with .attributedTo'] = async function () { 100 | const dbUrl = await listen(http.createServer(distbin())) 101 | const dh = distbinHtml.createHandler({ 102 | apiUrl: dbUrl, 103 | externalUrl: 'badurl' 104 | }) 105 | const dhUrl = await listen(http.createServer(dh)) 106 | const formFields = { 107 | content: 'lorem ipsum', 108 | 'attributedTo.name': 'Ben', 109 | 'attributedTo.url': 'http://bengo.is' 110 | } 111 | const activity = await postDistbinHtmlActivityForm(dbUrl, dhUrl, formFields) 112 | assert.equal(typeof activity.attributedTo, 'object') 113 | assert.equal(activity.attributedTo.name, formFields['attributedTo.name']) 114 | assert.equal(activity.attributedTo.url, formFields['attributedTo.url']) 115 | } 116 | 117 | tests['POST / can create activities with .tag'] = async function () { 118 | const dbUrl = await listen(http.createServer(distbin())) 119 | const dh = distbinHtml.createHandler({ 120 | apiUrl: dbUrl, 121 | externalUrl: 'badurl' 122 | }) 123 | const dhUrl = await listen(http.createServer(dh)) 124 | const formFields = { 125 | content: 'lorem ipsum', 126 | 'tag_csv': 'tag1,tag2' 127 | } 128 | const activity = await postDistbinHtmlActivityForm(dbUrl, dhUrl, formFields) 129 | assert.equal(Array.isArray(activity.object.tag), true) 130 | const tagNames = activity.object.tag.map(t => t.name) 131 | assert.equal(tagNames.includes('tag1'), true, 'tag includes tag1') 132 | assert.equal(tagNames.includes('tag2'), true, 'tag includes tag2') 133 | } 134 | 135 | async function postDistbinHtmlActivityForm (distbinUrl, distbinHtmlUrl, activityFormFields) { 136 | const postFormRequest = http.request(Object.assign(url.parse(distbinHtmlUrl), { 137 | method: 'POST', 138 | headers: { 139 | 'content-type': 'application/x-www-form-urlencoded' 140 | } 141 | })) 142 | postFormRequest.write(querystring.stringify(activityFormFields)) 143 | const dhResponse = await sendRequest(postFormRequest) 144 | assert.equal(dhResponse.statusCode, 302) 145 | assert(dhResponse.headers.location) 146 | // Ensure a generator was set 147 | // Note: getting from distbin, not distbin-html. 148 | const postedActivityUrl = url.resolve(distbinUrl, dhResponse.headers.location) 149 | const activityResponse = await sendRequest(http.request(Object.assign(url.parse(postedActivityUrl), { 150 | headers: { 151 | accept: 'application/json' 152 | } 153 | }))) 154 | const activity = JSON.parse(await readableToString(activityResponse)) 155 | return activity 156 | } 157 | 158 | tests['/activities/:id renders the .generator.name'] = async function () { 159 | const dbUrl = await listen(http.createServer(distbin())) 160 | const dh = distbinHtml.createHandler({ 161 | apiUrl: dbUrl, 162 | externalUrl: 'badurl' 163 | }) 164 | const dhUrl = await listen(http.createServer(dh)) 165 | const postFormRequest = http.request(Object.assign(url.parse(dhUrl), { 166 | method: 'POST', 167 | headers: { 168 | 'content-type': 'application/x-www-form-urlencoded' 169 | } 170 | })) 171 | postFormRequest.write(querystring.stringify({ 172 | name: 'activity name', 173 | content: 'This should have a generator.name of distbin-html' 174 | })) 175 | const dhResponse = await sendRequest(postFormRequest) 176 | assert.equal(dhResponse.statusCode, 302) 177 | assert(dhResponse.headers.location) 178 | // Ensure a generator was set 179 | const postedActivityUrl = url.resolve(dhUrl, dhResponse.headers.location) 180 | const activityResponse = await sendRequest(http.request(Object.assign(url.parse(postedActivityUrl), { 181 | headers: { 182 | accept: 'text/html' 183 | } 184 | }))) 185 | const activityHtml = await readableToString(activityResponse) 186 | assert.equal(activityResponse.statusCode, 200) 187 | const sanitized = sanitize.toText(activityHtml) 188 | assert(sanitized.includes('via distbin-html'), 'html response includes .generator.name') 189 | // todo rdfa? 190 | } 191 | 192 | if (require.main === module) { 193 | testCli(tests) 194 | } 195 | -------------------------------------------------------------------------------- /test/distbin.ts: -------------------------------------------------------------------------------- 1 | // tests for distbin-specific stuff (arbitrary, non-protocol things) 2 | 3 | import * as assert from "assert" 4 | import * as http from "http" 5 | import { get } from "lodash" 6 | import * as url from "url" 7 | import { testCli } from "." 8 | import distbin from "../" 9 | import { discoverOutbox } from "../src/activitypub" 10 | import * as activitypub from "../src/activitypub" 11 | import { ASJsonLdProfileContentType } from "../src/activitystreams" 12 | import { createLogger } from "../src/logger" 13 | import { Activity, ASObject, DistbinActivity, Extendable, HttpRequestResponder, isActivity, JSONLD, 14 | LDObject, LDValue, LDValues } from "../src/types" 15 | import { ensureArray, first, isProbablyAbsoluteUrl, linkToHref, readableToString, sendRequest } from "../src/util" 16 | import { listen, postActivity, requestForListener } from "./util" 17 | 18 | const logger = createLogger("test/distbin") 19 | const tests = module.exports 20 | 21 | tests.discoverOutbox = async () => { 22 | const distbinUrl = await listen(http.createServer(distbin())) 23 | const outbox = await discoverOutbox(distbinUrl) 24 | assert.equal(outbox, `${distbinUrl}/activitypub/outbox`) 25 | } 26 | 27 | tests["distbin can be imported"] = () => { 28 | assert(distbin, "distbin is truthy") 29 | } 30 | 31 | tests["can create a distbin"] = () => { 32 | distbin() 33 | } 34 | 35 | tests["can send http requests to a distbin.Server"] = async () => { 36 | const res = await sendRequest(await requestForListener(distbin())) 37 | assert.equal(res.statusCode, 200) 38 | } 39 | 40 | tests["/ route can be fetched as JSONLD and includes pointers to things like outbox"] = async () => { 41 | const res = await sendRequest(await requestForListener(distbin(), { 42 | headers: { 43 | accept: "application/ld+json", 44 | }, 45 | })) 46 | assert.equal(res.statusCode, 200) 47 | 48 | const resBody = await readableToString(res) 49 | const rootResource = JSON.parse(resBody) 50 | // #TODO: maybe a more fancy JSON-LD-aware check 51 | assert(Object.keys(rootResource).includes("outbox"), "/ points to outbox") 52 | assert(Object.keys(rootResource).includes("inbox"), "/ points to inbox") 53 | } 54 | 55 | tests["can fetch /recent to see what's been going on"] = async () => { 56 | const res = await sendRequest(await requestForListener(distbin(), { 57 | headers: { 58 | accept: "application/ld+json", 59 | }, 60 | path: "/recent", 61 | })) 62 | assert.equal(res.statusCode, 200) 63 | const resBody = await readableToString(res) 64 | const recentCollection = JSON.parse(resBody) 65 | assert.equal(recentCollection.type, "OrderedCollection") 66 | assert(Array.isArray(recentCollection.items), ".items is an Array") 67 | } 68 | 69 | tests["can page through /public collection.current"] = async () => { 70 | const d = distbin() 71 | const toCreate = [ 72 | { name: "first!" }, 73 | { name: "second" }, 74 | { name: "third" }, 75 | { name: "forth" }, 76 | ].map((a) => Object.assign(a, { 77 | cc: ["https://www.w3.org/ns/activitystreams#Public"], 78 | })) 79 | const created: string[] = [] 80 | for (const a of toCreate) { 81 | created.push(await postActivity(d, a)) 82 | } 83 | // const createdFull = await Promise.all(created.map(async function (url) { 84 | // return JSON.parse(await readableToString(await sendRequest(http.request(url)))) 85 | // })) 86 | // console.log('createdFull', createdFull) 87 | assert.equal(created.length, 4) 88 | const collectionUrl = "/activitypub/public" 89 | const collectionRes = await sendRequest(await requestForListener(d, { 90 | headers: { 91 | Prefer: 'return=representation; max-member-count="1"', 92 | accept: "application/ld+json", 93 | }, 94 | path: collectionUrl, 95 | })) 96 | const collection = JSON.parse(await readableToString(collectionRes)) 97 | assert.equal(collection.type, "Collection") 98 | assert.equal(collection.items.length, 1) 99 | // we get the most recently created one 100 | ensureArray(collection.items[0].url).forEach((itemUrl: string) => { 101 | assert.equal(url.parse(itemUrl).pathname, 102 | url.parse(created[created.length - 1]).pathname) 103 | }) 104 | assert(!collection.next, "collection does not have a next property") 105 | assert(collection.current, "collection has a .current property") 106 | assert(collection.first, "collection has a .first property") 107 | const page1Url = url.resolve(collectionUrl, linkToHref(collection.current)) 108 | // page 1 109 | const page1Res = await sendRequest(await requestForListener(d, { 110 | headers: { 111 | // NOTE! getting 2 this time 112 | Prefer: 'return=representation; max-member-count="1"', 113 | accept: "application/ld+json", 114 | }, 115 | path: page1Url, 116 | })) 117 | assert.equal(page1Res.statusCode, 200) 118 | const page1 = JSON.parse(await readableToString(page1Res)) 119 | assert.equal(page1.type, "OrderedCollectionPage") 120 | assert.equal(page1.startIndex, 0) 121 | assert.equal(page1.orderedItems.length, 1) 122 | assert(page1.next, "has a next property") 123 | 124 | // page 2 (get 2 items, not 1) 125 | const page2Url = url.resolve(page1Url, page1.next) 126 | const page2Res = await sendRequest(await requestForListener(d, { 127 | headers: { 128 | // NOTE! getting 2 this time 129 | Prefer: 'return=representation; max-member-count="2"', 130 | accept: "application/ld+json", 131 | }, 132 | path: page2Url, 133 | })) 134 | assert.equal(page2Res.statusCode, 200) 135 | const page2 = JSON.parse(await readableToString(page2Res)) 136 | assert.equal(page2.type, "OrderedCollectionPage") 137 | assert.equal(page2.startIndex, 1) 138 | assert.equal(page2.orderedItems.length, 2) 139 | assert(page2.next, "has a next property") 140 | // should have second most recently created 141 | ensureArray(page2.orderedItems[0].url).forEach((itemUrl: string) => 142 | assert.equal(url.parse(itemUrl).pathname, 143 | url.parse(created[created.length - 2]).pathname)) 144 | ensureArray(page2.orderedItems[1].url).forEach((itemUrl: string) => 145 | assert.equal(url.parse(itemUrl).pathname, 146 | url.parse(created[created.length - 3]).pathname)) 147 | // ok so if we post one more new thing, the startIndex on page2 should go up by one. 148 | const fifth = { 149 | cc: ["https://www.w3.org/ns/activitystreams#Public"], 150 | name: "fifth", 151 | } 152 | created.push(await postActivity(d, fifth)) 153 | const page2AfterFifthRes = await sendRequest(await requestForListener(d, { 154 | headers: { 155 | Prefer: 'return=representation; max-member-count="2"', 156 | accept: "application/ld+json", 157 | }, 158 | path: page2Url, 159 | })) 160 | const page2AfterFifth = JSON.parse(await readableToString(page2AfterFifthRes)) 161 | assert.equal(page2AfterFifth.startIndex, 2) 162 | // page 3 163 | const page3Url = url.resolve(page2Url, page2.next) 164 | const page3Res = await sendRequest(await requestForListener(d, { 165 | headers: { 166 | Prefer: 'return=representation; max-member-count="2"', 167 | accept: "application/ld+json", 168 | }, 169 | path: page3Url, 170 | })) 171 | assert.equal(page3Res.statusCode, 200) 172 | const page3 = JSON.parse(await readableToString(page3Res)) 173 | assert.equal(page3.type, "OrderedCollectionPage") 174 | assert.equal(page3.startIndex, 4) 175 | assert.equal(page3.orderedItems.length, 1) 176 | // assert.equal(url.parse(page3.orderedItems[0].url).pathname, url.parse(created[created.length - 5]).pathname) 177 | ensureArray(page3.orderedItems[0].url).forEach((itemUrl: string) => 178 | assert.equal(url.parse(itemUrl).pathname, 179 | url.parse(created[created.length - 5]).pathname)) 180 | // page3 can specify a next, but when fetched it shouldn't have any items 181 | // or continue pointing to next 182 | if (page3.next) { 183 | const page4Url = url.resolve(page3Url, page3.next) 184 | const page4Res = await sendRequest(await requestForListener(d, { 185 | headers: { 186 | Prefer: 'return=representation; max-member-count="2"', 187 | accept: "application/ld+json", 188 | }, 189 | path: page4Url, 190 | })) 191 | assert.equal(page4Res.statusCode, 200) 192 | const page4 = JSON.parse(await readableToString(page4Res)) 193 | assert.equal(page4.orderedItems.length, 0) 194 | assert(!page4.next) 195 | } 196 | } 197 | 198 | // Example 8,9: Submitting an Activity to the Outbox 199 | tests["posted activities have an .inbox (e.g. to receive replies in)"] = async () => { 200 | // Create an Activity by POSTing to outbox 201 | const distbinListener = distbin() 202 | const req = await requestForListener(distbinListener, { 203 | headers: activitypub.clientHeaders({ 204 | "content-type": ASJsonLdProfileContentType, 205 | }), 206 | method: "post", 207 | path: "/activitypub/outbox", 208 | }) 209 | req.write(JSON.stringify({ 210 | "@context": "https://www.w3.org/ns/activitypub", 211 | "content": "Hello, world", 212 | "type": "Article", 213 | })) 214 | const postActivityRequest = await sendRequest(req) 215 | assert.equal(postActivityRequest.statusCode, 201) 216 | // Determine Location of new Activity 217 | const location = first(postActivityRequest.headers.location) 218 | assert(location, "Location header is present in response") 219 | // Now get the new Activity 220 | 221 | const getActivityResponse = await sendRequest( 222 | await requestForListener(distbinListener, { 223 | headers: activitypub.clientHeaders(), 224 | path: location, 225 | }), 226 | ) 227 | assert.equal(getActivityResponse.statusCode, 200) 228 | const newActivity = JSON.parse(await readableToString(getActivityResponse)) 229 | 230 | assert(newActivity.inbox, "activity should have an .inbox property") 231 | } 232 | 233 | // #TODO is notifying the .inReplyTo inbox even encouraged/allowed by activitypub? 234 | tests["Posting a reply will notify the inReplyTo inbox (even if another distbin)"] = async () => { 235 | // ok so we're going to make two distbins, A and B, and test that A delivers to B 236 | const distbinA = distbin() 237 | const distbinB = distbin({ deliverToLocalhost: true }) 238 | // post a parent to distbinA 239 | const parentUrl = await postActivity(distbinA, { 240 | content: "Reply to this if you think FSW could happen", 241 | type: "Note", 242 | }) 243 | // ok now to post the reply to distbinB 244 | const replyUrl = await postActivity(distbinB, { 245 | cc: [parentUrl], 246 | content: "Dear Anonymous, I believe in FSW", 247 | inReplyTo: parentUrl, 248 | type: "Note", 249 | }) 250 | // then verify that it is in distbinA's inbox 251 | const replyId = JSON.parse(await readableToString(await sendRequest(http.request(replyUrl)))).id 252 | const distbinAInbox = JSON.parse(await readableToString(await sendRequest( 253 | await requestForListener(distbinA, "/activitypub/inbox")))) 254 | const replyFromDistbinAInbox = distbinAInbox.items.find((a: DistbinActivity) => { 255 | const idMatches = a.id === replyId 256 | if (idMatches) { return true } 257 | const wasDerivedFrom = a["http://www.w3.org/ns/prov#wasDerivedFrom"] 258 | if ( ! wasDerivedFrom) { return false } 259 | function nodeWasDerivedFrom(o: ASObject|string, nodeId: string): boolean { 260 | if (typeof o === "object") { return o.id === nodeId } else if (typeof o === "string") { return o === nodeId } 261 | return false 262 | } 263 | const matchesReplyId = (o: DistbinActivity|string): boolean => nodeWasDerivedFrom(o, replyId) 264 | if (wasDerivedFrom instanceof Array) { 265 | return wasDerivedFrom.some(matchesReplyId) 266 | } else if (isActivity(wasDerivedFrom) 267 | || typeof wasDerivedFrom === "string") { 268 | return matchesReplyId(wasDerivedFrom) 269 | } else if (typeof wasDerivedFrom === "object") { 270 | for (const id of [(wasDerivedFrom as ASObject).id, (wasDerivedFrom as JSONLD)["@id"]]) { 271 | if (typeof id === "string") { return matchesReplyId(id) } 272 | } 273 | return false 274 | } else { 275 | const exhaustiveCheck: never = wasDerivedFrom; 276 | } 277 | }) 278 | assert(replyFromDistbinAInbox, "distbinA inbox contains reply") 279 | assert.equal(isProbablyAbsoluteUrl(replyFromDistbinAInbox.replies), true, 280 | "activity is delivered with .replies as a valid absolute url") 281 | 282 | // So now distbinA is storing a replicated copy of the reply canonically hosted on distbinB. 283 | // What happens if we try to request this reply's id on distbinA 284 | // const replicatedReplyResponse = await sendRequest(await requestForListener(distbinA, { 285 | // path: '/activities/'+replyFromDistbinAInbox.uuid 286 | // })) 287 | // assert.equal(replicatedReplyResponse.statusCode, 302) 288 | // assert(isProbablyAbsoluteUrl(replicatedReplyResponse.headers.location), 'location header is absolute URL') 289 | } 290 | 291 | // #TODO is notifying the .inReplyTo inbox even encouraged/allowed by activitypub? 292 | tests["can configure spam checking for inbox to reject some things" + 293 | "(server:security-considerations:filter-incoming-content)"] = async () => { 294 | // ok so we're going to make two distbins, A and B, and test that A delivers to B 295 | const distbinA = distbin({ 296 | inboxFilter: async (obj: ASObject) => { 297 | const content = get(obj, "object.content") 298 | if (content && content.toLowerCase().includes("viagra")) { 299 | return false 300 | } 301 | return true 302 | }, 303 | }) 304 | const distbinB = distbin({ 305 | deliverToLocalhost: true, 306 | }) 307 | // post a parent to distbinA 308 | const parentUrl = await postActivity(distbinA, { 309 | content: "Spam me", 310 | type: "Note", 311 | }) 312 | // ok now to post the reply to distbinB 313 | const replyUrl = await postActivity(distbinB, { 314 | cc: [parentUrl], 315 | content: "Click here for free Viagra", 316 | inReplyTo: parentUrl, 317 | type: "Note", 318 | }) 319 | // then verify that it is NOT in distbinA's inbox 320 | const reply = JSON.parse(await readableToString(await sendRequest(http.request(replyUrl)))) 321 | const deliveryFailures = reply["distbin:activityPubDeliveryFailures"] 322 | assert.ok(deliveryFailures) 323 | assert.ok(deliveryFailures.some((failure: {message: string, name: string}) => { 324 | return failure.message.includes("This activity has been blocked by the configured inboxFilter") 325 | })) 326 | const distbinAInbox = JSON.parse(await readableToString(await sendRequest( 327 | await requestForListener(distbinA, "/activitypub/inbox")))) 328 | assert.equal(distbinAInbox.totalItems, 0, "distbinA inbox does NOT contain spam reply") 329 | } 330 | 331 | tests["When GET an activity, it has information about any replies it may have"] = async () => { 332 | // ok so we're going to make to distbins, A and B, and test that A delivers to B 333 | const distbinA = distbin() 334 | // post a parent to distbinA 335 | const parentUrl = await postActivity(distbinA, { 336 | content: "Reply to this if you think FSW could happen", 337 | type: "Note", 338 | }) 339 | // ok now to post the reply 340 | const replyUrl = await postActivity(distbinA, { 341 | cc: [parentUrl], 342 | content: "Dear Anonymous, I believe in FSW", 343 | inReplyTo: parentUrl, 344 | type: "Note", 345 | }) 346 | const reply = JSON.parse(await readableToString(await sendRequest(http.get(replyUrl)))) 347 | const parent = JSON.parse(await readableToString(await sendRequest(http.get(parentUrl)))) 348 | assert.equal(typeof parent.replies, "string", "has .replies URL") 349 | const repliesResponse = await sendRequest(http.get(url.resolve(parentUrl, parent.replies))) 350 | assert.equal(repliesResponse.statusCode, 200, "can fetch URL of .replies and get response") 351 | const repliesCollection = JSON.parse(await readableToString(repliesResponse)) 352 | // should be one reply 353 | assert.equal(repliesCollection.totalItems, 1, "replies collection .totalItems is right") 354 | assert(repliesCollection.items, "has .items") 355 | assert.equal(repliesCollection.items[0].id, reply.id, ".items contains the reply") 356 | } 357 | 358 | tests["Activities can have a .generator"] = async () => { 359 | const distbinA = distbin() 360 | const activityToPost = { 361 | content: "this has a generator", 362 | generator: { 363 | name: "distbin-html", 364 | type: "Application", 365 | url: "http://distbin.com", 366 | }, 367 | type: "Note", 368 | } 369 | const activityUrl = await postActivity(distbinA, activityToPost) 370 | const activity = JSON.parse(await readableToString(await sendRequest(http.get(activityUrl)))) 371 | // note: it was converted to a 'Create' activity 372 | assert(activity.object.generator, "has a generator") 373 | } 374 | 375 | tests["GET an activity has a .url that resolves"] = async () => { 376 | const activityUrl = await postActivity(distbin(), { 377 | content: "you can read this without knowing wtf JSON is!", 378 | type: "Note", 379 | }) 380 | const activityResponse = await sendRequest(http.request(Object.assign(url.parse(activityUrl), { 381 | headers: { 382 | accept: "text/html", 383 | }, 384 | }))) 385 | assert.equal(activityResponse.statusCode, 200) 386 | const fetchedActivity = JSON.parse(await readableToString(activityResponse)) 387 | assert(fetchedActivity.url, "has .url property") 388 | await Promise.all(ensureArray(fetchedActivity.url).map(async (fetchedActivityUrl: string) => { 389 | const resolvedUrl = url.resolve(activityUrl, fetchedActivityUrl) 390 | logger.debug("resolvedUrl", JSON.stringify({ fetchedActivityUrl, resolvedUrl, activityUrl, fetchedActivity }, null, 2)) 391 | const urlResponse = await sendRequest(http.request(resolvedUrl)) 392 | assert.equal(urlResponse.statusCode, 200) 393 | })) 394 | } 395 | 396 | tests["GET {activity.id}.json always sends json response, even if html if preferred by user-agent"] = async () => { 397 | const activityUrl = await postActivity(distbin(), { 398 | content: "Hi", 399 | type: "Note", 400 | }) 401 | const activityResponse = await sendRequest(http.request(Object.assign(url.parse(activityUrl + ".json"), { 402 | headers: { 403 | accept: "text/html,*/*", 404 | }, 405 | }))) 406 | assert.equal(activityResponse.statusCode, 200) 407 | const fetchedActivity = JSON.parse(await readableToString(activityResponse)) 408 | assert.ok(fetchedActivity) 409 | } 410 | 411 | if (require.main === module) { 412 | testCli(tests) 413 | } 414 | -------------------------------------------------------------------------------- /test/federation.ts: -------------------------------------------------------------------------------- 1 | // tests for distbin-specific stuff (arbitrary, non-protocol things) 2 | import { testCli } from "."; 3 | import distbin from "../"; 4 | import { discoverOutbox } from "../src/activitypub"; 5 | import { inboxUrl } from "../src/activitypub"; 6 | import { ASJsonLdProfileContentType } from "../src/activitystreams"; 7 | import { linkToHref } from "../src/util"; 8 | import { ensureArray, sendRequest } from "../src/util"; 9 | import { readableToString } from "../src/util"; 10 | import { isProbablyAbsoluteUrl } from "../src/util"; 11 | import { Activity, ASObject, DistbinActivity, Extendable, HttpRequestResponder, 12 | isActivity, JSONLD, LDObject, LDValue, LDValues } from "./types"; 13 | import { postActivity } from "./util"; 14 | import { listen } from "./util"; 15 | import { requestForListener } from "./util"; 16 | 17 | import * as assert from "assert"; 18 | import * as http from "http"; 19 | import { get } from "lodash"; 20 | import fetch from "node-fetch"; 21 | import * as url from "url"; 22 | 23 | const tests = module.exports; 24 | 25 | tests["On reply, notify inbox of parent's actor"] = async () => { 26 | const distbinForParentActor = distbin({ deliverToLocalhost: true }); 27 | const parentActorUrl = await listen(http.createServer(distbinForParentActor)) 28 | const parent = { 29 | actor: parentActorUrl, 30 | content: "Anyone out there?", 31 | } 32 | const parentUrl = await listen(http.createServer((request, response) => { 33 | response.writeHead(200, { "content-type": "application/json" }); 34 | response.end(JSON.stringify(parent, null, 2)) 35 | })) 36 | const distbinA = distbin({ deliverToLocalhost: true }); 37 | // post a reply 38 | const replyUrl = await postActivity(distbinA, { 39 | cc: [parentUrl], 40 | content: "Yes I am out there, parent", 41 | inReplyTo: parentUrl, 42 | }); 43 | const parentActorInboxResponse = await fetch(await inboxUrl(parentActorUrl)) 44 | const parentActorInbox = await parentActorInboxResponse.json() 45 | assert.equal(parentActorInbox.items.length, 1) 46 | }; 47 | 48 | if (require.main === module) { 49 | testCli(tests); 50 | } 51 | -------------------------------------------------------------------------------- /test/filemap.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | import * as fs from "fs" 3 | import * as os from "os" 4 | import * as path from "path" 5 | import { JSONFileMap, JSONFileMapAsync } from "../src/filemap" 6 | import { denodeify } from "../src/util" 7 | import { testCli } from "./" 8 | 9 | const tests = module.exports 10 | 11 | tests["has() works"] = withdir(async (dir: string) => { 12 | const filemap = new JSONFileMap(dir) 13 | filemap.set("key", "value") 14 | const url = "http://localhost:8000/activities/a79926ce-72da-4b69-9b4b-97fbb0509f2b" 15 | assert.equal(await filemap.has(url), false) 16 | await filemap.set(url, { a: 1 }) 17 | assert.equal(await filemap.has(url), true) 18 | }) 19 | 20 | tests["JSONFileMap can load old files"] = withdir(async (dir: string) => { 21 | const oldKey = "CF3F8888-30DD-42B6-9FF8-472292502FC1" 22 | const oldPath = path.join(dir, oldKey) 23 | const value = { id: oldKey, old: true } 24 | fs.writeFileSync(oldPath, JSON.stringify(value)) 25 | 26 | const filemap = new JSONFileMap(dir) 27 | const gotOldVal = filemap.get(oldKey) 28 | assert(gotOldVal, "could load old value") 29 | assert.equal(gotOldVal.id, oldKey) 30 | 31 | filemap.set(oldKey, Object.assign({ new: true }, value)) 32 | assert.equal(fs.existsSync(oldPath), true) 33 | assert.equal(fs.readdirSync(dir).length, 1) 34 | }) 35 | 36 | tests["JSONFileMapAsync can load old files"] = withdir(async (dir: string) => { 37 | const oldKey = "758F0F18-FD22-4F9A-BD2B-17F344F85ED2" 38 | const oldPath = path.join(dir, oldKey) 39 | const value = { id: oldKey, old: true } 40 | fs.writeFileSync(oldPath, JSON.stringify(value)) 41 | 42 | const filemap = new JSONFileMapAsync(dir) 43 | const gotOldVal = await filemap.get(oldKey) 44 | assert(gotOldVal, "could load old value") 45 | assert.equal(gotOldVal.id, oldKey) 46 | 47 | await filemap.set(oldKey, Object.assign({ new: true }, value)) 48 | assert.equal(fs.existsSync(oldPath), true) 49 | assert.equal(fs.readdirSync(dir).length, 1) 50 | }) 51 | 52 | tests["saves keys as files in dir, and values as file contents"] = withdir((dir: string) => { 53 | const filemap = new JSONFileMap(dir) 54 | filemap.set("key", "value") 55 | const files = fs.readdirSync(dir) 56 | assert.equal(files.length, 1) 57 | const filename = files[0] 58 | assert.equal(fs.readFileSync(path.join(dir, filename), "utf8"), '"value"') 59 | }) 60 | 61 | const timer = (ms: number) => new Promise((resolve, reject) => setTimeout(resolve, ms)) 62 | 63 | tests["iterates in insertion order (helped by fs created timestamp)"] = withdir(async (dir: string) => { 64 | const filemap = new JSONFileMap(dir) 65 | const insertionOrder = [1, 2, 10].map(String) 66 | for (const k of insertionOrder) { 67 | filemap.set(k, k + " value") 68 | // wait so that file creation times are at least 1ms apart. 69 | await timer(1) 70 | } 71 | assert.deepEqual(Array.from(filemap).map(([k, v]) => k), insertionOrder) 72 | // new filemaps from same dir should have same insertion order 73 | const filemap2 = new JSONFileMap(dir) 74 | assert.deepEqual(Array.from(filemap2).map(([k, v]) => k), insertionOrder) 75 | }) 76 | 77 | // create a temporary directory and pass its path to the provided function 78 | // no matter what happens, remove the folder 79 | function withdir(doWork: (dir: string) => any) { 80 | return async () => { 81 | const dir = await denodeify(fs.mkdtemp)(path.join(os.tmpdir(), "distbin-test-withdir-")) 82 | try { 83 | return await Promise.resolve(doWork(dir)) 84 | } finally { 85 | deleteFolderRecursive(dir) 86 | } 87 | } 88 | } 89 | 90 | // rm -rf 91 | function deleteFolderRecursive(dir: string) { 92 | if (fs.existsSync(dir)) { 93 | fs.readdirSync(dir).forEach((file: string) => { 94 | const curPath = path.join(dir, file) 95 | if (fs.lstatSync(curPath).isDirectory()) { // recurse 96 | deleteFolderRecursive(curPath) 97 | } else { // delete file 98 | fs.unlinkSync(curPath) 99 | } 100 | }) 101 | fs.rmdirSync(dir) 102 | } 103 | }; 104 | 105 | if (require.main === module) { 106 | testCli(tests) 107 | } 108 | -------------------------------------------------------------------------------- /test/http-utils.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | import {ClientRequest, ClientRequestArgs, ClientResponse} from "http" 3 | import fetch from "node-fetch" 4 | import * as url from "url" 5 | import distbin from "../" 6 | import { followRedirects, makeErrorClass, request, sendRequest } from "../src/util" 7 | import { testCli } from "./" 8 | 9 | const tests = module.exports 10 | 11 | tests["can follow redirects"] = async () => { 12 | const urlThatWillRedirect = "http://distbin.com/about" 13 | const response = await followRedirects(Object.assign(url.parse(urlThatWillRedirect), { 14 | headers: { 15 | accept: `application/json`, 16 | }, 17 | })) 18 | assert.equal(response.statusCode, 200) 19 | } 20 | 21 | if (require.main === module) { 22 | testCli(tests) 23 | } 24 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createLogger } from "../src/logger" 3 | 4 | const logger = createLogger("test") 5 | 6 | // Run tests if this file is executed 7 | if (require.main === module) { 8 | (async () => { 9 | const tests = await Promise.all([ 10 | import("./ldn"), 11 | import("./activitypub"), 12 | import("./distbin"), 13 | import("./federation"), 14 | import("./filemap"), 15 | import("./distbin-html"), 16 | import("./http-utils"), 17 | ]); 18 | await Promise.all(tests.map(run)) 19 | .then(() => process.exit()) 20 | .catch(() => process.exit(1)) 21 | })() 22 | } 23 | 24 | type Test = () => Promise<any> 25 | interface ITestsMap { 26 | [key: string]: Test 27 | } 28 | 29 | export async function testCli(tests: ITestsMap) { 30 | run(tests) 31 | .then(() => process.exit()) 32 | .catch((error: Error) => { 33 | logger.error("", error) 34 | process.exit(1) 35 | }) 36 | } 37 | 38 | // execute some tests (tests are object with test name/msg as key and func as val) 39 | // if env var TEST_FILTER is defined, only tests whose names contain that string will run 40 | export async function run(tests: ITestsMap) { 41 | const testFilter = process.env.TEST_FILTER 42 | const results = await Promise.all( 43 | // map to array of promises of logged errors 44 | // (or falsy if the test passed) 45 | Object.keys(tests) 46 | .map((testName) => [testName, tests[testName]]) 47 | .map(([testName, runTest]: [string, Test]) => { 48 | function logFailure(err: Error) { 49 | logger.error(`TEST FAIL: ${testName}\n${err.stack}\n`) 50 | } 51 | if (testFilter && testName.indexOf(testFilter) === -1) { 52 | // skip, doesn't match filter 53 | return 54 | } 55 | logger.debug("TEST: ", testName) 56 | let result 57 | try { 58 | result = runTest() 59 | } catch (err) { 60 | logFailure(err) 61 | return err 62 | } 63 | // result allowed to be a promise 64 | return Promise.resolve(result) 65 | .then(() => { 66 | // logger.log("PASS", testName) 67 | }) // return nothing if success 68 | .catch((err) => { 69 | logFailure(err) 70 | return err 71 | }) 72 | }), 73 | ) 74 | const failures = results.filter(Boolean) 75 | if (failures.length) { 76 | logger.error(`${failures.length} test failures`) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/ldn.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | import * as http from "http" 3 | import fetch from "node-fetch" 4 | import { v4 as uuid } from "node-uuid" 5 | import * as url from "url" 6 | import distbin from "../" 7 | import { jsonld } from "../src/util" 8 | import { testCli } from "./" 9 | import { listen } from "./util" 10 | 11 | const tests = module.exports 12 | 13 | tests["can OPTIONS inbox"] = async () => { 14 | const distbinUrl = await listen(http.createServer(distbin())) 15 | const res = await fetch(`${distbinUrl}/activitypub/inbox`, { method: "OPTIONS" }) 16 | assert.equal(res.status, 200) 17 | const acceptPost = res.headers.get("accept-post").split(",").map((m: string) => m.trim()) 18 | const shouldAcceptPostOf = [ 19 | "application/ld+json", "application/activity+json", "application/json", 20 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 21 | ] 22 | shouldAcceptPostOf.forEach((m) => { 23 | assert(acceptPost.includes(m), `Accept-Post header includes ${m}`) 24 | }) 25 | } 26 | 27 | tests["can GET inbox"] = async () => { 28 | const distbinUrl = await listen(http.createServer(distbin())) 29 | const notification = createNotification() 30 | // post first 31 | await fetch(`${distbinUrl}/activitypub/inbox`, { 32 | body: JSON.stringify(notification, null, 2), 33 | method: "POST", 34 | }) 35 | // get 36 | const res = await fetch(`${distbinUrl}/activitypub/inbox`, { 37 | headers: { 38 | accept: "application/ld+json", 39 | }, 40 | }) 41 | assert.equal(res.headers.get("content-type").split(";")[0], "application/ld+json") 42 | assert.equal(res.status, 200) 43 | const inbox = await res.json() 44 | const compaction = { 45 | "@context": [ 46 | "https://www.w3.org/ns/activitystreams", 47 | { 48 | "ldp:contains": { 49 | "@container": "@set", 50 | "@id": "ldp:contains", 51 | }, 52 | }, 53 | ], 54 | } 55 | const compacted = await jsonld.compact(inbox, compaction) 56 | // inbox needs @id to pass https://linkedresearch.org/ldn/tests/receiver 57 | assert(compacted.id, "inbox has an @id") 58 | const contains = compacted["ldp:contains"] 59 | assert(Array.isArray(contains)) 60 | assert.equal(contains.length, 1) 61 | const type = compacted.type 62 | assert((Array.isArray(type) ? type : [type]).includes("ldp:Container")) 63 | } 64 | 65 | tests["can POST notifications to inbox"] = async () => { 66 | const distbinUrl = await listen(http.createServer(distbin())) 67 | const notificationId = String(Math.random()).slice(2) 68 | const notification = createNotification({ id: notificationId }) 69 | // post 70 | const inboxUrl = `${distbinUrl}/activitypub/inbox` 71 | const res = await fetch(inboxUrl, { 72 | body: JSON.stringify(notification, null, 2), 73 | method: "POST", 74 | }) 75 | assert([201, 202].includes(res.status), "status is either 200 or 201") 76 | // response has a Location header 77 | const location = res.headers.get("location") 78 | assert(location, "POST notification responds with a Location header") 79 | const resolvedLocation = url.resolve(inboxUrl, location) 80 | // can GET that location 81 | const notificationRes = await fetch(resolvedLocation, { 82 | headers: { 83 | accept: "application/ld+json", 84 | }, 85 | }) 86 | assert.equal(notificationRes.status, 200, "can GET inbox notification URI") 87 | assert.equal( 88 | notificationRes.headers.get("content-type").split(";")[0], 89 | "application/ld+json", 90 | "notification GET responds with ld+json content-type", 91 | ) 92 | const gotNotification = await notificationRes.json() 93 | // new id is provisioned 94 | assert.notEqual(gotNotification.id, notification.id) 95 | // notifications once fetched are derivedFrom the thing that was sent 96 | const compaction = { "@context": [ 97 | { wasDerivedFrom: "http://www.w3.org/ns/prov#wasDerivedFrom" }, 98 | "http://www.w3.org/ns/activitystreams", 99 | ]} 100 | const compactedForDerivedFrom = await jsonld.compact(gotNotification, compaction) 101 | const wasDerivedFrom = compactedForDerivedFrom.wasDerivedFrom 102 | assert.ok(wasDerivedFrom) 103 | assert.equal(wasDerivedFrom.id, notificationId) 104 | } 105 | 106 | tests["fails gracefully on unexpected data in POST notifications to inbox"] = async () => { 107 | const distbinUrl = await listen(http.createServer(distbin())) 108 | const notification = { 109 | citation: { "@id": "http://example.org/article#results" }, 110 | } 111 | // post 112 | const res = await fetch(`${distbinUrl}/activitypub/inbox`, { 113 | body: JSON.stringify(notification, null, 2), 114 | method: "POST", 115 | }) 116 | assert([201, 202].includes(res.status), "status is either 200 or 201") 117 | } 118 | 119 | tests["Inbox handles notifications with ambiguous @id URIs by ignoring the id"] = async () => { 120 | const distbinUrl = await listen(http.createServer(distbin())) 121 | const notification = { 122 | "@context": "https://www.w3.org/ns/activitystreams", 123 | "@id": "./foo", 124 | } 125 | // post 126 | const inboxUrl = `${distbinUrl}/activitypub/inbox` 127 | const res = await fetch(inboxUrl, { 128 | body: JSON.stringify(notification, null, 2), 129 | headers: { 130 | "content-type": "application/ld+json", 131 | }, 132 | method: "POST", 133 | }) 134 | const location = url.resolve(inboxUrl, res.headers.get("location")) 135 | const notificationRes = await fetch(location, {headers: {accept: "application/ld+json"}}) 136 | const fetchedNotification = await notificationRes.json() 137 | assert.notEqual(fetchedNotification.id, notification["@id"], "notification got an unambiguous id provisioned") 138 | assert(!("@id" in fetchedNotification), "fetchedNotification does not have a @id") 139 | // TODO: but it should work if @base is specified 140 | } 141 | 142 | // tests['Inbox handles notifications relative @id and @base'] = async () => { 143 | // const distbinUrl = await listen(http.createServer(distbin())) 144 | // const notification = { 145 | // "@context": [{ 146 | // "@base": "http://bengo.is/", 147 | // }, "https://www.w3.org/ns/activitystreams"], 148 | // "@id": "i", 149 | // } 150 | // // post 151 | // const inboxUrl = `${distbinUrl}/activitypub/inbox` 152 | // const res = await fetch(inboxUrl, { 153 | // method: 'POST', 154 | // body: JSON.stringify(notification, null, 2), 155 | // headers: { 156 | // 'content-type': 'application/ld+json' 157 | // } 158 | // }) 159 | // const location = url.resolve(inboxUrl, res.headers.get('location')) 160 | // const notificationRes = await fetch(location, { headers: { accept: 'application/ld+json' }}) 161 | // const fetchedNotification = await notificationRes.json() 162 | // assert.equal(fetchedNotification.id, 'http://bengo.is/i') 163 | // assert( ! ('@id' in fetchedNotification), 'fetchedNotification does not have a @id') 164 | // } 165 | 166 | function createNotification(props = {}) { 167 | return Object.assign({ 168 | "@context": "https://www.w3.org/ns/activitystreams", 169 | "actor": { 170 | name: "Ben", 171 | }, 172 | "id": `urn:uuid:${uuid()}`, 173 | "object": { 174 | content: "<p>Hello, world!</p>", 175 | type: "Note", 176 | }, 177 | "type": "Create", 178 | }, props) 179 | } 180 | 181 | if (require.main === module) { 182 | testCli(tests) 183 | } 184 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | export * from "../src/types" 2 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | // common util functions for testing 2 | // (doesn't actually contain tests) 3 | import * as assert from "assert" 4 | import * as http from "http" 5 | import {IncomingMessage, RequestOptions, Server, ServerResponse} from "http" 6 | import * as url from "url" 7 | import * as activitypub from "../src/activitypub" 8 | import { ASJsonLdProfileContentType } from "../src/activitystreams" 9 | import {Activity, HttpRequestResponder, LDObject} from "../src/types" 10 | import { first, sendRequest } from "../src/util" 11 | 12 | // Return Promise of an http.Request that will be sent to an http.createServer listener 13 | export const requestForListener = async (listener: HttpRequestResponder, requestOptions?: RequestOptions|string) => { 14 | const server = http.createServer(listener) 15 | await listen(server) 16 | 17 | const request = http.request(Object.assign({ 18 | hostname: "localhost", 19 | method: "get", 20 | path: "/", 21 | port: server.address().port, 22 | }, typeof (requestOptions) === "string" ? { path: requestOptions } : requestOptions)) 23 | 24 | return request 25 | } 26 | 27 | // given an http.Server, return a promise of it listening on a port 28 | export const listen = (server: Server, port = 0, hostname?: string): Promise<string> => { 29 | let listened: boolean 30 | return new Promise((resolve, reject) => { 31 | server.once("error", (error: any) => { 32 | if (!listened) { reject(error) } 33 | }) 34 | server 35 | .listen(port, hostname, () => { 36 | listened = true 37 | resolve(`http://localhost:${server.address().port}`) 38 | }) 39 | }) 40 | } 41 | 42 | // post an activity to a distbin, and return its absolute url 43 | export const postActivity = async ( 44 | distbinListener: HttpRequestResponder, 45 | activity: LDObject<Activity>, 46 | ) => { 47 | const distbinUrl = await listen(http.createServer(distbinListener)) 48 | const req = http.request(Object.assign(url.parse(distbinUrl), { 49 | headers: activitypub.clientHeaders({ 50 | "content-type": ASJsonLdProfileContentType, 51 | }), 52 | method: "post", 53 | path: "/activitypub/outbox", 54 | })) 55 | req.write(JSON.stringify(activity)) 56 | const res = await sendRequest(req) 57 | assert.equal(res.statusCode, 201) 58 | const activityUrl = url.resolve(distbinUrl, first(res.headers.location)) 59 | return activityUrl 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "noImplicitAny": true, 5 | "types": ["node"], 6 | "allowJs": true, 7 | "outDir": "dist", 8 | "module": "commonjs", 9 | "target": "es6", 10 | "pretty": true, 11 | "lib": ["es7", "es2017"], 12 | "sourceMap": true 13 | }, 14 | "include": ["src/**/*.ts", "test/**/*.ts", "bin/**/*.ts"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "max-classes-per-file": false, 10 | "semicolon": false 11 | }, 12 | "rulesDirectory": [] 13 | } --------------------------------------------------------------------------------