From a1ac48d313cee4104c52ba6d0e6dbe97e7f9631b Mon Sep 17 00:00:00 2001 From: Benjamin Goering Date: Mon, 18 Mar 2019 12:35:59 -0300 Subject: [PATCH 1/4] Can specify externalUrl as url with subpath, can specify internalUrl if process cannot access externalUrl --- bin/client-address.ts | 2 +- bin/server.ts | 6 ++- config/index.ts | 2 + docker-compose.yml | 28 ++++++++++ etc/distbin-nginx-subpath/nginx.conf | 28 ++++++++++ package.json | 1 + src/activitypub.ts | 46 +++++++++++------ src/distbin-html/about.ts | 13 ++--- src/distbin-html/an-activity.ts | 76 +++++++++++++++++----------- src/distbin-html/home.ts | 27 ++++++---- src/distbin-html/index.ts | 22 +++++--- src/distbin-html/partials.ts | 14 ++--- src/distbin-html/public.ts | 15 ++++-- src/distbin-html/url-rewriter.ts | 9 ++++ src/index.ts | 25 +++++---- test/activitypub.ts | 4 +- test/distbin.ts | 6 ++- 17 files changed, 231 insertions(+), 93 deletions(-) create mode 100644 etc/distbin-nginx-subpath/nginx.conf create mode 100644 src/distbin-html/url-rewriter.ts diff --git a/bin/client-address.ts b/bin/client-address.ts index 7e36573..3f4697b 100755 --- a/bin/client-address.ts +++ b/bin/client-address.ts @@ -26,6 +26,6 @@ async function main() { ))) const urlBody = await readableToString(urlResponse) const fetchedObject = JSON.parse(urlBody) - const targets = objectTargets(fetchedObject, 0) + const targets = objectTargets(fetchedObject, 0, false, (u: string) => u) logger.info("", { targets }) } diff --git a/bin/server.ts b/bin/server.ts index 7ef1c8b..4c9f7e4 100755 --- a/bin/server.ts +++ b/bin/server.ts @@ -48,6 +48,7 @@ async function runServer() { } const externalUrl = distbinConfig.externalUrl || `http://localhost:${port}` + const internalUrl = distbinConfig.internalUrl || `http://localhost:${port}` const apiHandler = distbin(Object.assign( distbinConfig, ( ! distbinConfig.externalUrl ) && { externalUrl }, @@ -79,7 +80,10 @@ async function runServer() { } // html - const htmlServer = http.createServer(logMiddleware(distbinHtml.createHandler({ apiUrl: apiServerUrl, externalUrl }))) + const htmlServer = http.createServer(logMiddleware(distbinHtml.createHandler({ + apiUrl: apiServerUrl, + externalUrl, + internalUrl }))) const htmlServerUrl = await listen(htmlServer) // mainServer delegates to htmlHandler or distbin api handler based on Accept header diff --git a/config/index.ts b/config/index.ts index 4738ec1..2a31c62 100644 --- a/config/index.ts +++ b/config/index.ts @@ -12,6 +12,7 @@ interface IDistbinConfig { activities: IAsyncMap deliverToLocalhost: Boolean externalUrl?: string + internalUrl?: string inbox: IAsyncMap inboxFilter: InboxFilter port?: number @@ -26,6 +27,7 @@ export default async (): Promise => { ? JSON.parse(process.env.DISTBIN_DELIVER_TO_LOCALHOST) : process.env.NODE_ENV !== 'production', externalUrl: process.env.EXTERNAL_URL, + internalUrl: process.env.INTERNAL_URL, inbox: new JSONFileMapAsync(path.join(dbDir, 'inbox/')), inboxFilter: objectContentFilter(['viagra']), port: parsePort(process.env.PORT || process.env.npm_package_config_port), diff --git a/docker-compose.yml b/docker-compose.yml index cf4a9e5..83024fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,3 +15,31 @@ services: - 80 volumes: - distbin-db:/distbin-db:rw + + distbin-subpath-distbin: + command: npm run start:ts-node + environment: + - INTERNAL_URL=http://distbin-subpath-distbin:80/ + - EXTERNAL_URL=http://localhost:8001/distbin/ + - NODE_DEBUG=distbin + - LOG_LEVEL=debug + # - DISTBIN_DELIVER_TO_LOCALHOST=false + build: . + networks: + - public + ports: + - 80 + volumes: + - distbin-db:/distbin-db:rw + # - .:/home/distbin/app + + distbin-subpath: + depends_on: + - distbin-subpath-distbin + image: nginx:latest + networks: + - public + volumes: + - ./etc/distbin-nginx-subpath/nginx.conf:/etc/nginx/nginx.conf + ports: + - 8001:80 diff --git a/etc/distbin-nginx-subpath/nginx.conf b/etc/distbin-nginx-subpath/nginx.conf new file mode 100644 index 0000000..481618f --- /dev/null +++ b/etc/distbin-nginx-subpath/nginx.conf @@ -0,0 +1,28 @@ +events { worker_connections 1024; } + +http { + sendfile on; + rewrite_log on; + error_log /dev/stdout notice; + access_log /dev/stdout; + ignore_invalid_headers off; + + # upstream distbin-subpath-distbin { + # server distbin-subpath-distbin:80; + # } + + server { + location /distbin/ { + rewrite ^/distbin/(.*) /$1 break; + proxy_pass http://distbin-subpath-distbin/; + proxy_pass_request_headers on; + proxy_redirect ~^/(.*) $scheme://$http_host/distbin/$1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Host $server_name; + # proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/package.json b/package.json index f0681d5..49b31ac 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "test": "ts-node test", "start": "node ./dist/bin/server", "start-dev": "tsc-watch --onSuccess 'npm start'", + "start:ts-node": "ts-node bin/server.ts", "tsc": "tsc", "build": "tsc && npm run build.copyfiles", "build.copyfiles": "copyfiles './src/**/*.json' dist/" diff --git a/src/activitypub.ts b/src/activitypub.ts index 20e6820..2344be4 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -49,16 +49,21 @@ const flattenAnyArrays = (arr: Array): T[] => { * @param shouldFetch - whether to fetch related objects that are only mentioned by URL */ export const objectTargets = async ( - o: ASObject, recursionLimit: number, shouldFetch: boolean = false): Promise => { - // console.log('start objectTargets', recursionLimit, activity) - const audience = [...(await objectTargetsNoRecurse(o, shouldFetch)), + o: ASObject, + recursionLimit: number, + shouldFetch: boolean = false, + urlRewriter: (u: string) => string, +): Promise => { + logger.debug("start objectTargets", recursionLimit, o) + const audience = [...(await objectTargetsNoRecurse(o, shouldFetch, urlRewriter)), ...objectProvenanceAudience(o), ...targetedAudience(o)] - // console.log('objectTargets got audience', audience) + logger.debug("objectTargets got audience", audience) const recursedAudience = recursionLimit ? flattenAnyArrays(await Promise.all( - audience.map(async (audienceMemberr: ASObject) => { - const recursedTargets = await objectTargets(audienceMemberr, recursionLimit - 1, shouldFetch) + audience.map(async (audienceMember: ASObject) => { + const recursedTargets = await objectTargets( + audienceMember, recursionLimit - 1, shouldFetch, urlRewriter) return recursedTargets }), )) @@ -77,6 +82,7 @@ export const objectTargets = async ( export const objectTargetsNoRecurse = async ( o: ASObject, shouldFetch: boolean = false, + urlRewriter: (u: string) => string, // relatedObjectTargetedAudience is a MAY in the spec. // And if you leave it on and start replying to long chains, // you'll end up having to deliver to every ancestor, which takes a long time in @@ -87,7 +93,7 @@ export const objectTargetsNoRecurse = async ( target, inReplyTo and/or tag fields, retrieve their actor or attributedTo properties, and MAY also retrieve their addressing properties, and add these to the to or cc fields of the new Activity being created. */ - // console.log('isActivity(o)', isActivity(o), o) + // logger.debug('isActivity(o)', isActivity(o), o) const related = flattenAnyArrays([ isActivity(o) && o.object, isActivity(o) && o.target, @@ -96,18 +102,20 @@ export const objectTargetsNoRecurse = async ( o.inReplyTo, o.tag, ]).filter(Boolean) - // console.log('o.related', related) + logger.debug("o.related", related) const relatedObjects = (await Promise.all(related.map(async (objOrUrl) => { if (typeof objOrUrl === "object") { return objOrUrl; } if ( ! shouldFetch) { return } // fetch url to get an object const audienceUrl: string = objOrUrl // need to fetch it by url - const res = await sendRequest(request(Object.assign(url.parse(audienceUrl), { + logger.debug("about to fetch for audienceUrl", { audienceUrl, rewritten: urlRewriter(audienceUrl)}) + const res = await sendRequest(request(Object.assign(url.parse(urlRewriter(audienceUrl)), { headers: { accept: ASJsonLdProfileContentType, }, }))) + logger.debug("fetched audienceUrl", audienceUrl) if (res.statusCode !== 200) { logger.warn("got non-200 response when fetching ${obj} as part of activityAudience()") return @@ -129,7 +137,7 @@ export const objectTargetsNoRecurse = async ( return } }))).filter(Boolean) - // console.log('o.relatedObjects', relatedObjects) + // logger.debug('o.relatedObjects', relatedObjects) const relatedCreators: ASValue[] = flattenAnyArrays(relatedObjects.map(objectProvenanceAudience)) .filter(Boolean) @@ -168,8 +176,12 @@ export const targetedAudience = (object: ASObject|Activity) => { * @param activity */ export const clientAddressedActivity = async ( - activity: Activity, recursionLimit: number, shouldFetch: boolean = false): Promise => { - const audience = await objectTargets(activity, recursionLimit, shouldFetch) + activity: Activity, + recursionLimit: number, + shouldFetch: boolean = false, + urlRewriter: (urlToFetch: string) => string, +): Promise => { + const audience = await objectTargets(activity, recursionLimit, shouldFetch, urlRewriter) const audienceIds = audience.map(getASId) return Object.assign({}, activity, { cc: Array.from(new Set(jsonldAppend(activity.cc, audienceIds))).filter(Boolean), @@ -349,10 +361,12 @@ const deliverActivity = async ( // target export const targetAndDeliver = async ( activity: Activity, - targets?: string[], - deliverToLocalhost: boolean = true, + targets: string[], + deliverToLocalhost: boolean, + urlRewriter: (u: string) => string, ) => { - targets = targets || (await objectTargets(activity, 0)) + logger.debug("start targetAndDeliver") + targets = targets || (await objectTargets(activity, 0, false, urlRewriter)) .map((t) => { const targetUrl = getASId(t) if ( ! targetUrl) { logger.debug("Cant determine URL to deliver to for target, so skipping", t) } @@ -369,7 +383,7 @@ export const targetAndDeliver = async ( if (target === exports.publicCollectionId) { return Promise.resolve(target) } - return deliverActivity(activity, target, { deliverToLocalhost }) + return deliverActivity(activity, urlRewriter(target), { deliverToLocalhost }) .then((d) => deliveries.push(d)) .catch((e) => failures.push(e)) }), diff --git a/src/distbin-html/about.ts b/src/distbin-html/about.ts index 37b5ee4..cdaf606 100644 --- a/src/distbin-html/about.ts +++ b/src/distbin-html/about.ts @@ -2,20 +2,21 @@ import { requestUrl } from "../util" import { distbinBodyTemplate } from "./partials" import {IncomingMessage, ServerResponse} from "http" +import { resolve as urlResolve } from "url" export const createHandler = ({ externalUrl }: {externalUrl: string}) => { return (req: IncomingMessage, res: ServerResponse) => { res.writeHead(200, { "content-type": "text/html", }) - res.end(distbinBodyTemplate(` + res.end(distbinBodyTemplate({ externalUrl })(` ${createAboutMessage()} - ${createReplySection({ inReplyTo: requestUrl(req) })} + ${createReplySection({ externalUrl, inReplyTo: urlResolve(externalUrl, `.${req.url}`) })} `)) } } -function createReplySection({ inReplyTo }: {inReplyTo: string}) { +function createReplySection({ inReplyTo, externalUrl }: {inReplyTo: string, externalUrl: string}) { return ` -
+ diff --git a/src/distbin-html/an-activity.ts b/src/distbin-html/an-activity.ts index 407d213..dc3aafa 100644 --- a/src/distbin-html/an-activity.ts +++ b/src/distbin-html/an-activity.ts @@ -14,6 +14,7 @@ import { flatten } from "../util" import { distbinBodyTemplate } from "./partials" import { everyPageHead } from "./partials" import { sanitize } from "./sanitize" +import { internalUrlRewriter } from "./url-rewriter" import { IncomingMessage, ServerResponse } from "http" import * as marked from "marked" @@ -25,8 +26,8 @@ const logger = createLogger(__filename) const failedToFetch = Symbol("is this a Link that distbin failed to fetch?") // create handler to to render a single activity to a useful page -export const createHandler = ({apiUrl, activityId, externalUrl}: - {apiUrl: string, activityId: string, externalUrl: string}) => { +export const createHandler = ({apiUrl, activityId, externalUrl, internalUrl}: + {apiUrl: string, activityId: string, externalUrl: string, internalUrl: string}) => { return async (req: IncomingMessage, res: ServerResponse) => { const activityUrl = apiUrl + req.url const activityRes = await sendRequest(createHttpOrHttpsRequest(activityUrl)) @@ -44,15 +45,21 @@ export const createHandler = ({apiUrl, activityId, externalUrl}: return url.resolve(activityUrl, repliesUrl) }) - const descendants = flatten(await Promise.all(repliesUrls.map(fetchDescendants))) + debuglog("BEN about to fetchDescendants", { repliesUrls }) + const descendants = flatten(await Promise.all( + repliesUrls.map((repliesUrl) => fetchDescendants(repliesUrl, internalUrlRewriter(internalUrl, externalUrl))), + )) + debuglog("BEN after fetchDescendants") const activity = Object.assign(activityWithoutDescendants, { replies: descendants, }) - const ancestors = await fetchReplyAncestors(externalUrl, activity) + debuglog("BEN about to fetchReplyAncestors") + const ancestors = await fetchReplyAncestors(externalUrl, activity, internalUrlRewriter(internalUrl, externalUrl)) + debuglog("BEN after fetchReplyAncestors") - async function fetchDescendants(repliesUrl: string) { - const repliesCollectionResponse = await sendRequest(createHttpOrHttpsRequest(repliesUrl)) + async function fetchDescendants(repliesUrl: string, urlRewriter: (u: string) => string) { + const repliesCollectionResponse = await sendRequest(createHttpOrHttpsRequest(urlRewriter(repliesUrl))) if (repliesCollectionResponse.statusCode !== 200) { return { name: `Failed to fetch replies at ${repliesUrl} (code ${repliesCollectionResponse.statusCode})`, @@ -71,7 +78,7 @@ export const createHandler = ({apiUrl, activityId, externalUrl}: && replies[0] return Object.assign(withAbsoluteUrls, { replies: (typeof nextRepliesUrl === "string") - ? await fetchDescendants(nextRepliesUrl) + ? await fetchDescendants(nextRepliesUrl, urlRewriter) : replies, }) })) @@ -100,13 +107,13 @@ export const createHandler = ({apiUrl, activityId, externalUrl}: - ${distbinBodyTemplate(` - ${renderAncestorsSection(ancestors)} + ${distbinBodyTemplate({ externalUrl })(` + ${renderAncestorsSection(ancestors, externalUrl)}
- ${renderObject(activity)} + ${renderObject(activity, externalUrl)}
- ${renderDescendantsSection(ensureArray(activity.replies)[0])} + ${renderDescendantsSection(ensureArray(activity.replies)[0], externalUrl)} `)} - `) - } -} + `); + }; +}; // todo sandbox .content like /* @@ -152,199 +214,263 @@ export const createHandler = ({apiUrl, activityId, externalUrl, internalUrl}: scrolling="no" > */ -export const renderActivity = (activity: Activity, externalUrl: string) => renderObject(activity, externalUrl) - -type URLString = string -const href = (linkable: URLString|ASLink|ASObject): string => { - if (typeof linkable === "string") { return linkable; } - if (isASLink(linkable)) { return linkable.href } - if (linkable.url) { return href(first(linkable.url)) } - return -} +export const renderActivity = (activity: Activity, externalUrl: string) => + renderObject(activity, externalUrl); + +type URLString = string; +const href = (linkable: URLString | ASLink | ASObject): string => { + if (typeof linkable === "string") { + return linkable; + } + if (isASLink(linkable)) { + return linkable.href; + } + if (linkable.url) { + return href(first(linkable.url)); + } + return; +}; export function renderObject(activity: ASObject, externalUrl: string) { - const object = (isActivity(activity) && typeof activity.object === "object") ? activity.object : activity - const published = object.published - const generator = formatGenerator(activity) - const location = formatLocation(activity) - const attributedTo = formatAttributedTo(activity) - const tags = formatTags(activity) - const activityUrl = ensureArray(activity.url)[0] - const activityObject = isActivity(activity) && - ensureArray(activity.object).filter((o: ASObject|string) => typeof o === "object")[0] + const object = + isActivity(activity) && typeof activity.object === "object" + ? activity.object + : activity; + const published = object.published; + const generator = formatGenerator(activity); + const location = formatLocation(activity); + const attributedTo = formatAttributedTo(activity); + const tags = formatTags(activity); + const activityUrl = ensureArray(activity.url)[0]; + const activityObject = + isActivity(activity) && + ensureArray(activity.object).filter( + (o: ASObject | string) => typeof o === "object", + )[0]; const mainHtml = (() => { try { - const maybeMarkdown = - activity.content - ? activity.content - : activityObject && (typeof activityObject === "object") && activityObject.content - ? activityObject.content + const maybeMarkdown = activity.content + ? activity.content + : activityObject && + typeof activityObject === "object" && + activityObject.content + ? activityObject.content : activity.name || activity.url - ? `${activity.url}` + ? `${activity.url}` : activity.id - ? `${activity.id}` - : "" - const html = marked(maybeMarkdown) + ? `${activity.id}` + : ""; + const html = marked(maybeMarkdown); const sanitized = sanitize(html); return sanitized; } catch (error) { - logger.error("Error rendering activity object.", activity, error) - return `

distbin failed to render this

` + logger.error("Error rendering activity object.", activity, error); + return `

distbin failed to render this

`; } - })() + })(); return `
${attributedTo || ""}
${ - activity.name - ? `

${activity.name}

` - : activityObject && (typeof activityObject === "object") && activityObject.name - ? `

${activityObject.name}

` - : "" -} + activity.name + ? `

${activity.name}

` + : activityObject && + typeof activityObject === "object" && + activityObject.name + ? `

${activityObject.name}

` + : "" + }
${mainHtml}
${ - tags - ? ` + tags + ? `
${tags}
` - : "" -} + : "" + }
- ${ensureArray(isActivity(activity) && (typeof activity.object === "object") && activity.object.attachment) + ${ensureArray( + isActivity(activity) && + typeof activity.object === "object" && + activity.object.attachment, + ) .map((attachment: ASObject & HasLinkPrefetchResult) => { - if (!attachment) { return "" } + if (!attachment) { + return ""; + } switch (attachment.type) { case "Link": - const prefetch: LinkPrefetchResult = attachment["https://distbin.com/ns/linkPrefetch"] + const prefetch: LinkPrefetchResult = + attachment["https://distbin.com/ns/linkPrefetch"]; if (prefetch.type === "LinkPrefetchFailure") { - return + return; } - const linkPrefetchSuccess = prefetch as LinkPrefetchSuccess - if (!(linkPrefetchSuccess && linkPrefetchSuccess.supportedMediaTypes)) { return "" } - if (linkPrefetchSuccess.supportedMediaTypes.find((m: string) => m.startsWith("image/"))) { - return linkPrefetchSuccess.link && ` + const linkPrefetchSuccess = prefetch as LinkPrefetchSuccess; + if ( + !( + linkPrefetchSuccess && + linkPrefetchSuccess.supportedMediaTypes + ) + ) { + return ""; + } + if ( + linkPrefetchSuccess.supportedMediaTypes.find((m: string) => + m.startsWith("image/"), + ) + ) { + return ( + linkPrefetchSuccess.link && + ` ` + ); } - break + break; default: - break + break; } - return "" + return ""; }) .filter(Boolean) - .join("\n") - } + .join("\n")}
- ${/* TODO add byline */""} + ${/* TODO add byline */ ""}
- ` + `; } function formatTags(o: ASObject) { - const tags = ensureArray(isActivity(o) && typeof o.object === "object" && o.object.tag - || o.tag).filter(Boolean) - return tags.map(renderTag).filter(Boolean).join(" ") + const tags = ensureArray( + (isActivity(o) && typeof o.object === "object" && o.object.tag) || o.tag, + ).filter(Boolean); + return tags + .map(renderTag) + .filter(Boolean) + .join(" "); function renderTag(tag: ASObject) { - const text = tag.name || tag.id || first(tag.url) - if (!text) { return } - const safeText = encodeHtmlEntities(text) - const tagUrl = tag.url || tag.id || (isProbablyAbsoluteUrl(text) ? text : "") - let rendered + const text = tag.name || tag.id || first(tag.url); + if (!text) { + return; + } + const safeText = encodeHtmlEntities(text); + const tagUrl = + tag.url || tag.id || (isProbablyAbsoluteUrl(text) ? text : ""); + let rendered; if (tagUrl) { - rendered = `${safeText}` + rendered = `${safeText}`; } else { - rendered = `${safeText}` + rendered = `${safeText}`; } - return rendered + return rendered; } } -function formatAttributedTo(activity: ASObject|Activity) { - const attributedTo = activity.attributedTo - || (isActivity(activity)) && (typeof activity.object === "object") && activity.object.attributedTo - if (!attributedTo) { return } - let formatted = "" - let authorUrl +function formatAttributedTo(activity: ASObject | Activity) { + const attributedTo = + activity.attributedTo || + (isActivity(activity) && + typeof activity.object === "object" && + activity.object.attributedTo); + if (!attributedTo) { + return; + } + let formatted = ""; + let authorUrl; if (typeof attributedTo === "string") { - formatted = encodeHtmlEntities(attributedTo) + formatted = encodeHtmlEntities(attributedTo); } else if (typeof attributedTo === "object") { - formatted = encodeHtmlEntities(attributedTo.name || first(attributedTo.url)) - authorUrl = attributedTo.url + formatted = encodeHtmlEntities( + attributedTo.name || first(attributedTo.url), + ); + authorUrl = attributedTo.url; } if (authorUrl) { - formatted = `` + formatted = ``; + } + if (!formatted) { + return; } - if (!formatted) { return } return `
${formatted}
- ` + `; } function formatLocation(activity: ASObject) { - const location: Place = activity && activity.location - if (!location) { return } - let imgUrl - let linkTo + const location: Place = activity && activity.location; + if (!location) { + return; + } + let imgUrl; + let linkTo; if (location.latitude && location.longitude) { imgUrl = [ `https://maps.googleapis.com/maps/api/staticmap?`, - `center=${location.latitude},${location.longitude}&zoom=11&size=480x300&sensor=false`, - ].join("") - linkTo = `https://www.openstreetmap.org/search?query=${location.latitude},${location.longitude}` + `center=${location.latitude},${ + location.longitude + }&zoom=11&size=480x300&sensor=false`, + ].join(""); + linkTo = `https://www.openstreetmap.org/search?query=${location.latitude},${ + location.longitude + }`; } else if (location.name) { // use name as center, don't specify zoom - imgUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${location.name}&size=480x300&sensor=false` - linkTo = `https://www.openstreetmap.org/search?query=${location.name}` + imgUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${ + location.name + }&size=480x300&sensor=false`; + linkTo = `https://www.openstreetmap.org/search?query=${location.name}`; } const glyph = ` - + 🌍 - ` + `; if (!imgUrl) { - return glyph + return glyph; } return `
@@ -354,8 +480,16 @@ function formatLocation(activity: ASObject) {
    ${location.latitude ? `
  • latitude: ${location.latitude}
  • ` : ""} ${location.longitude ? `
  • longitude: ${location.longitude}
  • ` : ""} - ${location.altitude ? `
  • altitude: ${location.altitude}${location.units || "m"}
  • ` : ""} - ${location.radius ? `
  • radius: ${location.radius}${location.units || "m"}
  • ` : ""} + ${ + location.altitude + ? `
  • altitude: ${location.altitude}${location.units || "m"}
  • ` + : "" + } + ${ + location.radius + ? `
  • radius: ${location.radius}${location.units || "m"}
  • ` + : "" + } ${location.accuracy ? `
  • accuracy: ${location.accuracy}%
  • ` : ""}
@@ -364,26 +498,37 @@ function formatLocation(activity: ASObject) {
- ` + `; } function formatGenerator(o: ASObject) { - const object: ASObject = isActivity(o) && (typeof o.object === "object") && o.object - const generator = object && object.generator - if (!generator) { return "" } - let generatorText + const object: ASObject = + isActivity(o) && typeof o.object === "object" && o.object; + const generator = object && object.generator; + if (!generator) { + return ""; + } + let generatorText; if (typeof generator === "object") { - if (generator.name) { generatorText = generator.name } else if (generator.id) { generatorText = generator.id } - let generatorUrl - if (generator.url) { generatorUrl = generator.url } else if (generator.id) { generatorUrl = generator.id } + if (generator.name) { + generatorText = generator.name; + } else if (generator.id) { + generatorText = generator.id; + } + let generatorUrl; + if (generator.url) { + generatorUrl = generator.url; + } else if (generator.id) { + generatorUrl = generator.id; + } if (generatorUrl) { - return `${generatorText}` + return `${generatorText}`; } } if (generatorText) { - return generatorText + return generatorText; } - return "" + return ""; } export const createActivityCss = () => { @@ -441,224 +586,299 @@ export const createActivityCss = () => { .action-show-raw pre { color: initial } - ` -} + `; +}; class ASObjectWithFetchedReplies extends ASObject { - public replies: Collection + public replies: Collection; } -function renderDescendantsSection(replies: Collection, externalUrl: string) { - let inner = "" - if (replies.totalItems === 0) { return "" } +function renderDescendantsSection( + replies: Collection, + externalUrl: string, +) { + let inner = ""; + if (replies.totalItems === 0) { + return ""; + } if (!replies.items && replies.name) { - inner = replies.name + inner = replies.name; } else if (replies.items.length === 0) { - inner = "uh... totalItems > 0 but no items included. #TODO" + inner = "uh... totalItems > 0 but no items included. #TODO"; } else { - inner = replies.items.map((a: ASObjectWithFetchedReplies) => ` + inner = replies.items + .map( + (a: ASObjectWithFetchedReplies) => ` ${renderObject(a, externalUrl)} ${renderDescendantsSection(a.replies, externalUrl)} - `).join("") + `, + ) + .join(""); } return `
${inner}
- ` + `; } // Render a single ancestor activity -function renderAncestor(ancestor: Activity|LinkPrefetchFailure, externalUrl: string): string { +function renderAncestor( + ancestor: Activity | LinkPrefetchFailure, + externalUrl: string, +): string { if (ancestor.type === "LinkPrefetchFailure") { - const linkFetchFailure = ancestor as LinkPrefetchFailure - const linkHref = linkFetchFailure.link.href + const linkFetchFailure = ancestor as LinkPrefetchFailure; + const linkHref = linkFetchFailure.link.href; // assume its a broken link return `
- ${linkHref} (${linkFetchFailure.error || "couldn't fetch more info"}) + ${linkHref} (${linkFetchFailure.error || + "couldn't fetch more info"})
- ` + `; } - return renderObject(ancestor, externalUrl) + return renderObject(ancestor, externalUrl); } // Render an item and its ancestors for each ancestor in the array. // This results in a nested structure conducive to indent-styling -function renderAncestorsSection(ancestors: Array = [], externalUrl: string): string { - if (!ancestors.length) { return "" } - const [ancestor, ...olderAncestors] = ancestors +function renderAncestorsSection( + ancestors: Array = [], + externalUrl: string, +): string { + if (!ancestors.length) { + return ""; + } + const [ancestor, ...olderAncestors] = ancestors; return `
- ${olderAncestors.length ? renderAncestorsSection(olderAncestors, externalUrl) : ""} + ${ + olderAncestors.length + ? renderAncestorsSection(olderAncestors, externalUrl) + : "" + } ${renderAncestor(ancestor, externalUrl)}
- ` + `; } async function fetchReplyAncestors( baseUrl: string, activity: Activity, urlRewriter: (u: string) => string, -): Promise> { - const inReplyTo = flatten(ensureArray(activity.object) - .filter((o: object|string): o is object => typeof o === "object") - .map((o: Activity) => ensureArray(o.inReplyTo)), - )[0] - const parentUrl = inReplyTo && url.resolve(baseUrl, href(inReplyTo)) +): Promise> { + const inReplyTo = flatten( + ensureArray(activity.object) + .filter((o: object | string): o is object => typeof o === "object") + .map((o: Activity) => ensureArray(o.inReplyTo)), + )[0]; + const parentUrl = inReplyTo && url.resolve(baseUrl, href(inReplyTo)); if (!parentUrl) { - return [] + return []; } - let parent: Activity + let parent: Activity; try { - parent = activityWithUrlsRelativeTo(await fetchActivity(urlRewriter(parentUrl)), parentUrl) + parent = activityWithUrlsRelativeTo( + await fetchActivity(urlRewriter(parentUrl)), + parentUrl, + ); } catch (err) { switch (err.code) { case "ECONNREFUSED": case "ENOTFOUND": case "ENETUNREACH": // don't recurse since we can't fetch the parent - return [new LinkPrefetchFailure({ - error: err, - link: { - href: parentUrl, - type: "Link", - }, - })] + return [ + new LinkPrefetchFailure({ + error: err, + link: { + href: parentUrl, + type: "Link", + }, + }), + ]; } - throw err + throw err; } // #TODO support limiting at some reasonable amount of depth to avoid too big - const ancestorsOfParent = await fetchReplyAncestors(baseUrl, parent, urlRewriter) - const ancestorsOrFailures = [parent, ...ancestorsOfParent] - return ancestorsOrFailures + const ancestorsOfParent = await fetchReplyAncestors( + baseUrl, + parent, + urlRewriter, + ); + const ancestorsOrFailures = [parent, ...ancestorsOfParent]; + return ancestorsOrFailures; } async function fetchActivity(activityUrl: string) { - const activityUrlOrRedirect = activityUrl - let activityResponse = await sendRequest(createHttpOrHttpsRequest(Object.assign(url.parse(activityUrlOrRedirect), { - headers: { - accept: `${ASJsonLdProfileContentType}, text/html`, - }, - }))) - let redirectsLeft = 3 + const activityUrlOrRedirect = activityUrl; + let activityResponse = await sendRequest( + createHttpOrHttpsRequest( + Object.assign(url.parse(activityUrlOrRedirect), { + headers: { + accept: `${ASJsonLdProfileContentType}, text/html`, + }, + }), + ), + ); + let redirectsLeft = 3; /* eslint-disable no-labels */ followRedirects: while (redirectsLeft > 0) { switch (activityResponse.statusCode) { case 301: case 302: - const resolvedUrl = url.resolve(activityUrl, ensureArray(activityResponse.headers.location)[0]) - activityResponse = await sendRequest(createHttpOrHttpsRequest(Object.assign(url.parse(resolvedUrl), { - headers: { - accept: `${ASJsonLdProfileContentType}, text/html`, - }, - }))) - redirectsLeft-- - continue followRedirects + const resolvedUrl = url.resolve( + activityUrl, + ensureArray(activityResponse.headers.location)[0], + ); + activityResponse = await sendRequest( + createHttpOrHttpsRequest( + Object.assign(url.parse(resolvedUrl), { + headers: { + accept: `${ASJsonLdProfileContentType}, text/html`, + }, + }), + ), + ); + redirectsLeft--; + continue followRedirects; case 406: // unacceptable. Server doesn't speak a content-type I know. return { url: activityUrl, - } + }; case 200: // cool - break followRedirects + break followRedirects; default: - logger.warn("unexpected fetchActivity statusCode", activityResponse.statusCode, activityUrl) - break followRedirects + logger.warn( + "unexpected fetchActivity statusCode", + activityResponse.statusCode, + activityUrl, + ); + break followRedirects; } } /* eslint-enable no-labels */ const resContentType = activityResponse.headers["content-type"] - // strip off params like charset, profile, etc - ? ensureArray(activityResponse.headers["content-type"])[0].split(";")[0].toLowerCase() - : undefined + ? // strip off params like charset, profile, etc + ensureArray(activityResponse.headers["content-type"])[0] + .split(";")[0] + .toLowerCase() + : undefined; switch (resContentType) { case "application/json": case "application/activity+json": - const a = JSON.parse(await readableToString(activityResponse)) + const a = JSON.parse(await readableToString(activityResponse)); // ensure there is a .url value return Object.assign(a, { url: a.url || activityUrl, - }) + }); case "text/html": // Make an activity-like thing return { url: activityUrl, // TODO parse for .name ? - } + }; default: - throw new Error("Unexpected fetched activity content-type: " + resContentType + " " + activityUrl + " ") + throw new Error( + "Unexpected fetched activity content-type: " + + resContentType + + " " + + activityUrl + + " ", + ); } } -const isRelativeUrl = (u: string) => u && ! url.parse(u).host +const isRelativeUrl = (u: string) => u && !url.parse(u).host; // given an activity with some URL values as maybe relative URLs, // return the activity with them made absolute URLs // TODO: use json-ld logic for this incl e.g. @base -function activityWithUrlsRelativeTo(activity: Activity, relativeTo: string): Activity { +function activityWithUrlsRelativeTo( + activity: Activity, + relativeTo: string, +): Activity { interface IUrlUpdates { - replies?: typeof activity.replies, - url?: typeof activity.url, + replies?: typeof activity.replies; + url?: typeof activity.url; } - const updates: IUrlUpdates = {} + const updates: IUrlUpdates = {}; const resolveUrl = (baseUrl: string, relativeUrl: string): string => { // prepend '.' to baseUrl can have subpath and not get dropped - const resolved = url.resolve(baseUrl, `.${relativeUrl}`) + const resolved = url.resolve(baseUrl, `.${relativeUrl}`); return resolved; - } + }; if (activity.replies) { - updates.replies = ((replies) => { - if (typeof replies === "string" && isRelativeUrl(replies)) { return resolveUrl(relativeTo, replies) } - return replies - })(activity.replies) + updates.replies = (replies => { + if (typeof replies === "string" && isRelativeUrl(replies)) { + return resolveUrl(relativeTo, replies); + } + return replies; + })(activity.replies); } if (activity.url) { - updates.url = ensureArray(activity.url).map((u) => { - if (typeof u === "string" && isRelativeUrl(u)) { return resolveUrl(relativeTo, u) } + updates.url = ensureArray(activity.url).map(u => { + if (typeof u === "string" && isRelativeUrl(u)) { + return resolveUrl(relativeTo, u); + } if (isASLink(u) && isRelativeUrl(u.href)) { return Object.assign({}, u, { href: resolveUrl(relativeTo, u.href), - }) + }); } - return u - }) + return u; + }); } - const withAbsoluteUrls = Object.assign({}, activity, updates) - return withAbsoluteUrls + const withAbsoluteUrls = Object.assign({}, activity, updates); + return withAbsoluteUrls; } function formatDate(date: Date, relativeTo = new Date()) { - const MONTH_STRINGS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - const diffMs = date.getTime() - relativeTo.getTime() - let dateString + const MONTH_STRINGS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const diffMs = date.getTime() - relativeTo.getTime(); + let dateString; // Future if (diffMs > 0) { - throw new Error("formatDate cannot format dates in the future") + throw new Error("formatDate cannot format dates in the future"); } // Just now (0s) if (diffMs > -1000) { - return "1s" + return "1s"; } // Less than 60s ago -> 5s if (diffMs > -60 * 1000) { - return Math.round(-1 * diffMs / 1000) + "s" + return Math.round((-1 * diffMs) / 1000) + "s"; } // Less than 1h ago -> 5m if (diffMs > -60 * 60 * 1000) { - return Math.round(-1 * diffMs / (1000 * 60)) + "m" + return Math.round((-1 * diffMs) / (1000 * 60)) + "m"; } // Less than 24h ago -> 5h if (diffMs > -60 * 60 * 24 * 1000) { - return Math.round(-1 * diffMs / (1000 * 60 * 60)) + "hrs" + return Math.round((-1 * diffMs) / (1000 * 60 * 60)) + "hrs"; } // >= 24h ago -> 6 Jul - dateString = date.getDate() + " " + MONTH_STRINGS[date.getMonth()] + dateString = date.getDate() + " " + MONTH_STRINGS[date.getMonth()]; // or like 6 Jul 2012 if the year if its different than the relativeTo year if (date.getFullYear() !== relativeTo.getFullYear()) { - dateString += " " + date.getFullYear() + dateString += " " + date.getFullYear(); } - return dateString -}; + return dateString; +} diff --git a/src/distbin-html/home.ts b/src/distbin-html/home.ts index 8d651a3..81bdaa6 100644 --- a/src/distbin-html/home.ts +++ b/src/distbin-html/home.ts @@ -1,72 +1,90 @@ +import * as http from "http"; +import { IncomingMessage, ServerResponse } from "http"; +import * as querystring from "querystring"; +import * as url from "url"; +import { publicCollectionId } from "../activitypub"; +import { clientAddressedActivity } from "../activitypub"; +import { discoverOutbox } from "../activitypub"; +import { ASJsonLdProfileContentType } from "../activitystreams"; +import { Activity, ASObject } from "../activitystreams"; +import { + ASLink, + HasLinkPrefetchResult, + LinkPrefetchFailure, + LinkPrefetchResult, + LinkPrefetchSuccess, +} from "../types"; +import { encodeHtmlEntities, readableToString, sendRequest } from "../util"; +import { requestUrl } from "../util"; +import { isProbablyAbsoluteUrl } from "../util"; +import { createHttpOrHttpsRequest } from "../util"; +import { debuglog, first } from "../util"; +import { distbinBodyTemplate } from "./partials"; +import { internalUrlRewriter } from "./url-rewriter"; -import * as http from "http" -import {IncomingMessage, ServerResponse} from "http" -import * as querystring from "querystring" -import * as url from "url" -import { publicCollectionId } from "../activitypub" -import { clientAddressedActivity } from "../activitypub" -import { discoverOutbox } from "../activitypub" -import { ASJsonLdProfileContentType } from "../activitystreams" -import { Activity, ASObject } from "../activitystreams" -import { ASLink, HasLinkPrefetchResult, LinkPrefetchFailure, LinkPrefetchResult, LinkPrefetchSuccess } from "../types" -import { encodeHtmlEntities, readableToString, sendRequest } from "../util" -import { requestUrl } from "../util" -import { isProbablyAbsoluteUrl } from "../util" -import { createHttpOrHttpsRequest } from "../util" -import { debuglog, first } from "../util" -import { distbinBodyTemplate } from "./partials" -import { internalUrlRewriter } from "./url-rewriter" +import { createLogger } from "../logger"; +const logger = createLogger(__filename); -import { createLogger } from "../logger" -const logger = createLogger(__filename) - -export const createHandler = ( - { apiUrl, externalUrl, internalUrl }: {apiUrl: string, externalUrl: string, internalUrl: string}) => { +export const createHandler = ({ + apiUrl, + externalUrl, + internalUrl, +}: { + apiUrl: string; + externalUrl: string; + internalUrl: string; +}) => { return async (req: IncomingMessage, res: ServerResponse) => { switch (req.method.toLowerCase()) { // POST is form submission to create a new post case "post": - const submission = await readableToString(req) + const submission = await readableToString(req); // assuming application/x-www-form-urlencoded - const formFields = querystring.parse(submission) - const { attachment } = formFields - const inReplyTo = first(formFields.inReplyTo) - const firstAttachment = first(attachment) + const formFields = querystring.parse(submission); + const { attachment } = formFields; + const inReplyTo = first(formFields.inReplyTo); + const firstAttachment = first(attachment); if (firstAttachment && !isProbablyAbsoluteUrl(firstAttachment)) { - throw new Error("attachment must be a URL, but got " + firstAttachment) + throw new Error( + "attachment must be a URL, but got " + firstAttachment, + ); } - const attachmentLink = await getAttachmentLinkForUrl(firstAttachment) + const attachmentLink = await getAttachmentLinkForUrl(firstAttachment); - let location + let location; try { - location = parseLocationFormFields(formFields) + location = parseLocationFormFields(formFields); } catch (error) { - logger.error(error) - throw new Error("Error parsing location form fields") + logger.error(error); + throw new Error("Error parsing location form fields"); } - let attributedTo = {} as any + let attributedTo = {} as any; if (formFields["attributedTo.name"]) { - attributedTo.name = formFields["attributedTo.name"] + attributedTo.name = formFields["attributedTo.name"]; } - const attributedToUrl = first(formFields["attributedTo.url"]) + const attributedToUrl = first(formFields["attributedTo.url"]); if (attributedToUrl) { if (!isProbablyAbsoluteUrl(attributedToUrl)) { - throw new Error("Invalid non-URL value for attributedTo.url: " + attributedToUrl) + throw new Error( + "Invalid non-URL value for attributedTo.url: " + attributedToUrl, + ); } - attributedTo.url = attributedToUrl + attributedTo.url = attributedToUrl; } if (Object.keys(attributedTo).length === 0) { - attributedTo = undefined + attributedTo = undefined; } - let tag + let tag; if (formFields.tag_csv) { - tag = first(formFields.tag_csv).split(",").map((n: string) => { - return { - name: n.trim(), - } - }) + tag = first(formFields.tag_csv) + .split(",") + .map((n: string) => { + return { + name: n.trim(), + }; + }); } const note: ASObject = Object.assign( @@ -83,61 +101,78 @@ export const createHandler = ( type: "Note", }, inReplyTo ? { inReplyTo } : {}, - ) + ); const unaddressedActivity: Activity = { "@context": "https://www.w3.org/ns/activitystreams", attributedTo, - "cc": [publicCollectionId, inReplyTo].filter(Boolean), + cc: [publicCollectionId, inReplyTo].filter(Boolean), location, - "object": note, - "type": "Create", - } - debuglog("about to await clientAddressedActivity", {unaddressedActivity}); + object: note, + type: "Create", + }; + debuglog("about to await clientAddressedActivity", { + unaddressedActivity, + }); const addressedActivity = await clientAddressedActivity( - unaddressedActivity, 0, true, internalUrlRewriter(internalUrl, externalUrl)) - debuglog("addressedActivity", addressedActivity) + unaddressedActivity, + 0, + true, + internalUrlRewriter(internalUrl, externalUrl), + ); + debuglog("addressedActivity", addressedActivity); // submit to outbox // #TODO discover outbox URL - debuglog("about to discoverOutbox", { apiUrl }) - const outboxUrl = await discoverOutbox(apiUrl) - debuglog("distbin-html/home is posting to outbox", {apiUrl, outboxUrl}) + debuglog("about to discoverOutbox", { apiUrl }); + const outboxUrl = await discoverOutbox(apiUrl); + debuglog("distbin-html/home is posting to outbox", { + apiUrl, + outboxUrl, + }); const postToOutboxRequest = http.request( - Object.assign(url.parse(internalUrlRewriter(internalUrl, externalUrl)(outboxUrl)), { - headers: { - "content-type": ASJsonLdProfileContentType, + Object.assign( + url.parse(internalUrlRewriter(internalUrl, externalUrl)(outboxUrl)), + { + headers: { + "content-type": ASJsonLdProfileContentType, + }, + method: "post", }, - method: "post", - }), - ) - postToOutboxRequest.write(JSON.stringify(addressedActivity)) - const postToOutboxResponse = await sendRequest(postToOutboxRequest) + ), + ); + postToOutboxRequest.write(JSON.stringify(addressedActivity)); + const postToOutboxResponse = await sendRequest(postToOutboxRequest); switch (postToOutboxResponse.statusCode) { case 201: - const postedLocation = postToOutboxResponse.headers.location + const postedLocation = postToOutboxResponse.headers.location; // handle form submission by posting to outbox - res.writeHead(302, { location: postedLocation }) - res.end() - break + res.writeHead(302, { location: postedLocation }); + res.end(); + break; case 500: - res.writeHead(500) - postToOutboxResponse.pipe(res) - break + res.writeHead(500); + postToOutboxResponse.pipe(res); + break; default: - throw new Error("unexpected upstream response") + throw new Error("unexpected upstream response"); } - break + break; // GET renders home page will all kinds of stuff case "get": - const query = url.parse(req.url, true).query // todo sanitize - const safeInReplyToDefault = encodeHtmlEntities(first(query.inReplyTo) || "") - const safeTitleDefault = encodeHtmlEntities(first(query.title) || "") - const safeAttachmentUrl = encodeHtmlEntities(first(query.attachment) || "") + const query = url.parse(req.url, true).query; // todo sanitize + const safeInReplyToDefault = encodeHtmlEntities( + first(query.inReplyTo) || "", + ); + const safeTitleDefault = encodeHtmlEntities(first(query.title) || ""); + const safeAttachmentUrl = encodeHtmlEntities( + first(query.attachment) || "", + ); res.writeHead(200, { "content-type": "text/html", - }) + }); /* tslint:disable:max-line-length */ - res.write(distbinBodyTemplate({ externalUrl })(` - ${(` + res.write( + distbinBodyTemplate({ externalUrl })(` + ${` <style> .post-form textarea { height: calc(100% - 14em - 8px); /* everything except the rest of this form */ @@ -289,7 +324,7 @@ export const createHandler = ( contentInput.focus(); }()) </script> - `)} + `} <details> <summary>or POST via API</summary> <pre>${encodeHtmlEntities(` @@ -304,83 +339,90 @@ curl -XPOST "${requestUrl(req)}activitypub/outbox" -d @- <<EOF EOF`)} </pre> </details> - `)) + `), + ); /* tslint:enable:max-line-length */ - res.end() + res.end(); } - } -} + }; +}; -function parseLocationFormFields(formFields: {[key: string]: string|string[]}) { +function parseLocationFormFields(formFields: { + [key: string]: string | string[]; +}) { interface ILocation { - type: string - name: string - units: string - altitude: number - latitude: number - longitude: number - accuracy: number - radius: number + type: string; + name: string; + units: string; + altitude: number; + latitude: number; + longitude: number; + accuracy: number; + radius: number; } - const location = { type: "Place" } as ILocation - const formFieldPrefix = "location." - const prefixed = (name: string) => `${formFieldPrefix}${name}` + const location = { type: "Place" } as ILocation; + const formFieldPrefix = "location."; + const prefixed = (name: string) => `${formFieldPrefix}${name}`; const floatFieldNames: Array<keyof ILocation> = [ "latitude", "longitude", "altitude", "accuracy", "radius", - ] + ]; if (formFields[prefixed("name")]) { - location.name = first(formFields["location.name"]) + location.name = first(formFields["location.name"]); } if (formFields[prefixed("units")]) { - location.units = first(formFields["location.units"]) + location.units = first(formFields["location.units"]); } floatFieldNames.forEach((k: keyof ILocation) => { - const fieldVal = first(formFields[prefixed(k)]) - if (!fieldVal) { return } - location[k] = parseFloat(fieldVal) - }) + const fieldVal = first(formFields[prefixed(k)]); + if (!fieldVal) { + return; + } + location[k] = parseFloat(fieldVal); + }); if (Object.keys(location).length === 1) { // there were no location formFields - return + return; } - return location + return location; } async function getAttachmentLinkForUrl(attachment: string) { const attachmentLink: ASLink & HasLinkPrefetchResult = attachment && { href: attachment, type: "Link", - } - let linkPrefetchResult: LinkPrefetchResult + }; + let linkPrefetchResult: LinkPrefetchResult; if (attachment && attachmentLink) { // try to request the URL to figure out what kind of media type it responds with // then we can store a hint to future clients that render it - let connectionError - let attachmentResponse + let connectionError; + let attachmentResponse; try { - attachmentResponse = await sendRequest(createHttpOrHttpsRequest(Object.assign(url.parse(attachment)))) + attachmentResponse = await sendRequest( + createHttpOrHttpsRequest(Object.assign(url.parse(attachment))), + ); } catch (error) { - connectionError = error - logger.warn("Error prefetching attachment URL " + attachment) - logger.error(error) + connectionError = error; + logger.warn("Error prefetching attachment URL " + attachment); + logger.error(error); } if (connectionError) { linkPrefetchResult = new LinkPrefetchFailure({ error: { message: connectionError.message, }, - }) + }); } else if (attachmentResponse.statusCode === 200) { - const contentType = attachmentResponse.headers["content-type"] + const contentType = attachmentResponse.headers["content-type"]; if (contentType) { linkPrefetchResult = new LinkPrefetchSuccess({ published: new Date().toISOString(), supportedMediaTypes: [contentType], - }) + }); } } else { // no connection error, not 200, must be another @@ -388,11 +430,11 @@ async function getAttachmentLinkForUrl(attachment: string) { error: { status: attachmentResponse.statusCode, }, - }) + }); } - attachmentLink["https://distbin.com/ns/linkPrefetch"] = linkPrefetchResult + attachmentLink["https://distbin.com/ns/linkPrefetch"] = linkPrefetchResult; } - return attachmentLink + return attachmentLink; } // function createMoreInfo(req, apiUrl) { diff --git a/src/distbin-html/index.ts b/src/distbin-html/index.ts index aab4a41..c92d284 100644 --- a/src/distbin-html/index.ts +++ b/src/distbin-html/index.ts @@ -1,14 +1,14 @@ -import { route, RoutePattern, RouteResponderFactory } from "../util" +import { route, RoutePattern, RouteResponderFactory } from "../util"; -import * as about from "./about" -import * as anActivity from "./an-activity" -import * as home from "./home" -import * as publicSection from "./public" +import * as about from "./about"; +import * as anActivity from "./an-activity"; +import * as home from "./home"; +import * as publicSection from "./public"; -import {IncomingMessage, ServerResponse} from "http" +import { IncomingMessage, ServerResponse } from "http"; -import { createLogger } from "../logger" -const logger = createLogger(__filename) +import { createLogger } from "../logger"; +const logger = createLogger(__filename); interface IDistbinHtmlHandlerOptions { apiUrl: string; @@ -16,28 +16,47 @@ interface IDistbinHtmlHandlerOptions { internalUrl: string; } -export const createHandler = ({ apiUrl, externalUrl, internalUrl }: IDistbinHtmlHandlerOptions) => { +export const createHandler = ({ + apiUrl, + externalUrl, + internalUrl, +}: IDistbinHtmlHandlerOptions) => { const routes = new Map<RoutePattern, RouteResponderFactory>([ - [new RegExp("^/$"), () => home.createHandler({ apiUrl, externalUrl, internalUrl })], + [ + new RegExp("^/$"), + () => home.createHandler({ apiUrl, externalUrl, internalUrl }), + ], [new RegExp("^/about$"), () => about.createHandler({ externalUrl })], - [new RegExp("^/public$"), () => publicSection.createHandler({ apiUrl, externalUrl })], - [new RegExp("^/activities/([^/.]+)$"), - (activityId: string) => anActivity.createHandler({ apiUrl, activityId, externalUrl, internalUrl })], - ]) + [ + new RegExp("^/public$"), + () => publicSection.createHandler({ apiUrl, externalUrl }), + ], + [ + new RegExp("^/activities/([^/.]+)$"), + (activityId: string) => + anActivity.createHandler({ + activityId, + apiUrl, + externalUrl, + internalUrl, + }), + ], + ]); return (req: IncomingMessage, res: ServerResponse) => { - const handler = route(routes, req) + const handler = route(routes, req); if (!handler) { - res.writeHead(404) - res.end("404 Not Found") - return + res.writeHead(404); + res.end("404 Not Found"); + return; } - Promise.resolve((async () => { - return handler(req, res) - })()) - .catch((e) => { - res.writeHead(500) - logger.error(e, e.stack) - res.end("Error: " + e.stack) - }) - } -} + Promise.resolve( + (async () => { + return handler(req, res); + })(), + ).catch(e => { + res.writeHead(500); + logger.error(e, e.stack); + res.end("Error: " + e.stack); + }); + }; +}; diff --git a/src/distbin-html/partials.ts b/src/distbin-html/partials.ts index f89f55d..e7cd084 100644 --- a/src/distbin-html/partials.ts +++ b/src/distbin-html/partials.ts @@ -26,16 +26,20 @@ export const everyPageHead = () => ` max-width: 100%; } </style> -` +`; export const aboveFold = (html: string) => ` <div class="distbin-above-fold"> ${html} </div> -` +`; // wrap page with common body template for distbin-html (e.g. header/footer) -export const distbinBodyTemplate = ({ externalUrl }: { externalUrl: string }) => (page: string) => ` +export const distbinBodyTemplate = ({ + externalUrl, +}: { + externalUrl: string; +}) => (page: string) => ` <head> ${everyPageHead()} </head> @@ -43,7 +47,7 @@ export const distbinBodyTemplate = ({ externalUrl }: { externalUrl: string }) => <div class="distbin-main"> ${page} </div> -` +`; function header({ externalUrl }: { externalUrl: string }) { // todo @@ -86,10 +90,16 @@ function header({ externalUrl }: { externalUrl: string }) { <a href="${externalUrl}" class="distbin-header-item name">distbin</a> </div> <div class="distbin-header-section right"> - <a href="${urlResolve(externalUrl, "./public")}" class="distbin-header-item public">public</a> - <a href="${urlResolve(externalUrl, "./about")}" class="distbin-header-item about">about</a> + <a href="${urlResolve( + externalUrl, + "./public", + )}" class="distbin-header-item public">public</a> + <a href="${urlResolve( + externalUrl, + "./about", + )}" class="distbin-header-item about">about</a> </div> </div> </header> - ` + `; } diff --git a/src/distbin-html/public.ts b/src/distbin-html/public.ts index 24f0a67..47c3a96 100644 --- a/src/distbin-html/public.ts +++ b/src/distbin-html/public.ts @@ -1,65 +1,87 @@ -import { IncomingMessage, ServerResponse } from "http" -import * as querystring from "querystring" -import * as url from "url" +import { IncomingMessage, ServerResponse } from "http"; +import * as querystring from "querystring"; +import * as url from "url"; import { Activity } from "../types"; import { sendRequest } from "../util"; -import { encodeHtmlEntities } from "../util" -import { first } from "../util" -import { readableToString } from "../util" -import { requestMaxMemberCount } from "../util" -import { createHttpOrHttpsRequest } from "../util" -import { linkToHref } from "../util" -import { createActivityCss, renderActivity } from "./an-activity" -import { distbinBodyTemplate } from "./partials" +import { encodeHtmlEntities } from "../util"; +import { first } from "../util"; +import { readableToString } from "../util"; +import { requestMaxMemberCount } from "../util"; +import { createHttpOrHttpsRequest } from "../util"; +import { linkToHref } from "../util"; +import { createActivityCss, renderActivity } from "./an-activity"; +import { distbinBodyTemplate } from "./partials"; -export const createHandler = ({ apiUrl, externalUrl }: {apiUrl: string, externalUrl: string}) => { +export const createHandler = ({ + apiUrl, + externalUrl, +}: { + apiUrl: string; + externalUrl: string; +}) => { return async (req: IncomingMessage, res: ServerResponse) => { res.writeHead(200, { "content-type": "text/html", - }) - res.end(distbinBodyTemplate({ externalUrl })(` + }); + res.end( + distbinBodyTemplate({ externalUrl })(` ${await createPublicBody(req, { apiUrl, externalUrl, })} - `)) - } -} + `), + ); + }; +}; -async function createPublicBody(req: IncomingMessage, { apiUrl, externalUrl }: {apiUrl: string, externalUrl: string}) { - const limit = requestMaxMemberCount(req) || 10 +async function createPublicBody( + req: IncomingMessage, + { apiUrl, externalUrl }: { apiUrl: string; externalUrl: string }, +) { + const limit = requestMaxMemberCount(req) || 10; if (typeof limit !== "number") { - throw new Error("max-member-count must be a number") + throw new Error("max-member-count must be a number"); } - const query = url.parse(req.url, true).query - let pageUrl = first(query.page) - let pageMediaType = query.pageMediaType || "application/json" + const query = url.parse(req.url, true).query; + let pageUrl = first(query.page); + let pageMediaType = query.pageMediaType || "application/json"; if (!pageUrl) { - const publicCollectionUrl = apiUrl + "/activitypub/public" - const publicCollectionRequest = createHttpOrHttpsRequest(Object.assign(url.parse(publicCollectionUrl), { - headers: { - Prefer: `return=representation; max-member-count="${limit}"`, - }, - })) - const publicCollection = JSON.parse(await readableToString(await sendRequest(publicCollectionRequest))) - pageUrl = url.resolve(publicCollectionUrl, linkToHref(publicCollection.current)) + const publicCollectionUrl = apiUrl + "/activitypub/public"; + const publicCollectionRequest = createHttpOrHttpsRequest( + Object.assign(url.parse(publicCollectionUrl), { + headers: { + Prefer: `return=representation; max-member-count="${limit}"`, + }, + }), + ); + const publicCollection = JSON.parse( + await readableToString(await sendRequest(publicCollectionRequest)), + ); + pageUrl = url.resolve( + publicCollectionUrl, + linkToHref(publicCollection.current), + ); if (typeof publicCollection.current === "object") { - pageMediaType = publicCollection.current.mediaType || pageMediaType + pageMediaType = publicCollection.current.mediaType || pageMediaType; } } - const pageRequest = createHttpOrHttpsRequest(Object.assign(url.parse(pageUrl), { - headers: { - Accept: pageMediaType, - Prefer: `return=representation; max-member-count="${limit}"`, - }, - })) - const pageResponse = await sendRequest(pageRequest) - const page = JSON.parse(await readableToString(pageResponse)) - const nextQuery = page.next && Object.assign({}, url.parse(req.url, true).query, { - page: page.next && url.resolve(pageUrl, linkToHref(page.next)), - }) - const nextUrl = nextQuery && `?${querystring.stringify(nextQuery)}` - const externalPageUrl = pageUrl.replace(apiUrl, externalUrl) + const pageRequest = createHttpOrHttpsRequest( + Object.assign(url.parse(pageUrl), { + headers: { + Accept: pageMediaType, + Prefer: `return=representation; max-member-count="${limit}"`, + }, + }), + ); + const pageResponse = await sendRequest(pageRequest); + const page = JSON.parse(await readableToString(pageResponse)); + const nextQuery = + page.next && + Object.assign({}, url.parse(req.url, true).query, { + page: page.next && url.resolve(pageUrl, linkToHref(page.next)), + }); + const nextUrl = nextQuery && `?${querystring.stringify(nextQuery)}`; + const externalPageUrl = pageUrl.replace(apiUrl, externalUrl); const msg = ` <style> ${createActivityCss()} @@ -68,14 +90,15 @@ async function createPublicBody(req: IncomingMessage, { apiUrl, externalUrl }: { <p>Fetched from <a href="${externalPageUrl}">${externalPageUrl}</a></p> <details> <summary>{…}</summary> - <pre><code>${ - encodeHtmlEntities( - // #TODO: discover /public url via HATEOAS - JSON.stringify(page, null, 2), - ) - // linkify values of 'url' property (quotes encode to ") - .replace(/"url": "(.+?)(?=")"/g, '"url": "<a href="$1">$1</a>"') -}</code></pre> + <pre><code>${encodeHtmlEntities( + // #TODO: discover /public url via HATEOAS + JSON.stringify(page, null, 2), + ) + // linkify values of 'url' property (quotes encode to ") + .replace( + /"url": "(.+?)(?=")"/g, + '"url": "<a href="$1">$1</a>"', + )}</code></pre> </details> <div> ${(page.orderedItems || page.items || []) @@ -83,17 +106,13 @@ async function createPublicBody(req: IncomingMessage, { apiUrl, externalUrl }: { .join("\n")} </div> <p> - ${ - [ - page.startIndex - ? `${page.startIndex} previous items` - : "", - nextUrl - ? `<a href="${nextUrl}">Next Page</a>` - : "", - ].filter(Boolean).join(" - ") -} + ${[ + page.startIndex ? `${page.startIndex} previous items` : "", + nextUrl ? `<a href="${nextUrl}">Next Page</a>` : "", + ] + .filter(Boolean) + .join(" - ")} </p> - ` - return msg + `; + return msg; } diff --git a/src/distbin-html/sanitize.ts b/src/distbin-html/sanitize.ts index 9a6ef50..0e96de5 100644 --- a/src/distbin-html/sanitize.ts +++ b/src/distbin-html/sanitize.ts @@ -1,6 +1,6 @@ // tslint:disable:no-var-requires -const createDOMPurify = require("dompurify") -const jsdom = require("jsdom") +const createDOMPurify = require("dompurify"); +const jsdom = require("jsdom"); // tslint:enable:no-var-requires const window = jsdom.jsdom("", { @@ -8,12 +8,12 @@ const window = jsdom.jsdom("", { FetchExternalResources: false, // disables resource loading over HTTP / filesystem ProcessExternalResources: false, // do not execute JS within script blocks }, -}).defaultView +}).defaultView; -const DOMPurify = createDOMPurify(window) +const DOMPurify = createDOMPurify(window); -export const sanitize = DOMPurify.sanitize.bind(DOMPurify) +export const sanitize = DOMPurify.sanitize.bind(DOMPurify); exports.toText = (html: string) => { - return DOMPurify.sanitize(html, { ALLOWED_TAGS: ["#text"] }) -} + return DOMPurify.sanitize(html, { ALLOWED_TAGS: ["#text"] }); +}; diff --git a/src/distbin-html/url-rewriter.ts b/src/distbin-html/url-rewriter.ts index b6598b0..47a412c 100644 --- a/src/distbin-html/url-rewriter.ts +++ b/src/distbin-html/url-rewriter.ts @@ -1,9 +1,10 @@ -import { debuglog } from "../util" +import { debuglog } from "../util"; export function internalUrlRewriter(internalUrl: string, externalUrl: string) { - debuglog("internalUrlRewriter", { internalUrl, externalUrl }) + debuglog("internalUrlRewriter", { internalUrl, externalUrl }); if (internalUrl && externalUrl) { - return (urlToRewrite: string) => urlToRewrite.replace(externalUrl, internalUrl) + return (urlToRewrite: string) => + urlToRewrite.replace(externalUrl, internalUrl); } - return (urlToRewrite: string) => urlToRewrite + return (urlToRewrite: string) => urlToRewrite; } diff --git a/src/filemap.ts b/src/filemap.ts index 4eb5a78..fd8d3ae 100644 --- a/src/filemap.ts +++ b/src/filemap.ts @@ -1,115 +1,132 @@ -import * as fs from "fs" -import * as path from "path" -import { denodeify } from "./util" +import * as fs from "fs"; +import * as path from "path"; +import { denodeify } from "./util"; -import { createLogger } from "../src/logger" -const logger = createLogger("filemap") +import { createLogger } from "../src/logger"; +const logger = createLogger("filemap"); const filenameEncoder = { decode: (filename: string) => { - const pattern = /^data:(.+)?(;base64)?,([^$]*)$/ - const match = filename.match(pattern) - if (!match) { return filename } - const base64encoded = match[3] - return Buffer.from(base64encoded, "base64").toString() + const pattern = /^data:(.+)?(;base64)?,([^$]*)$/; + const match = filename.match(pattern); + if (!match) { + return filename; + } + const base64encoded = match[3]; + return Buffer.from(base64encoded, "base64").toString(); }, encode: (key: string) => { - const base64encoded = Buffer.from(key).toString("base64") - return `data:base64,${base64encoded}` + const base64encoded = Buffer.from(key).toString("base64"); + return `data:base64,${base64encoded}`; }, -} +}; // TODO: Write tests // Like a Map, but keys are files in a dir, and object values are written as file contents -export const JSONFileMap = class <V> extends Map<string, V> { +export const JSONFileMap = class<V> extends Map<string, V> { constructor(private dir: string) { - super() + super(); } public ["set"](key: string, val: V) { - const pathForKey = this.keyToExistentPath(key) || this.keyToPath(key) + const pathForKey = this.keyToExistentPath(key) || this.keyToPath(key); // coerce to string - const valString = JSON.stringify(val, null, 2) - fs.writeFileSync(pathForKey, valString) - return this + const valString = JSON.stringify(val, null, 2); + fs.writeFileSync(pathForKey, valString); + return this; } public ["get"](key: string) { - const pathForKey = this.keyToExistentPath(key) - if (!pathForKey) { return } - const fileContents = fs.readFileSync(pathForKey, "utf8") - return JSON.parse(fileContents) + const pathForKey = this.keyToExistentPath(key); + if (!pathForKey) { + return; + } + const fileContents = fs.readFileSync(pathForKey, "utf8"); + return JSON.parse(fileContents); } public has(key: string) { - const got = this.get(key) - return Boolean(got) + const got = this.get(key); + return Boolean(got); } public ["delete"](key: string) { - const pathForKey = this.keyToExistentPath(key) - if (pathForKey) { fs.unlinkSync(pathForKey) } - return true + const pathForKey = this.keyToExistentPath(key); + if (pathForKey) { + fs.unlinkSync(pathForKey); + } + return true; } public [Symbol.iterator]() { - return this.entries()[Symbol.iterator]() + return this.entries()[Symbol.iterator](); } public keys() { - const dir = this.dir - const files = fs.readdirSync(dir) + const dir = this.dir; + const files = fs.readdirSync(dir); const sortedAscByCreation: string[] = files - .map((name) => { - const stat = fs.statSync(path.join(dir, name)) - return ({ name, stat }) + .map(name => { + const stat = fs.statSync(path.join(dir, name)); + return { name, stat }; }) .sort((a, b) => { - const timeDelta = a.stat.ctime.getTime() - b.stat.ctime.getTime() + const timeDelta = a.stat.ctime.getTime() - b.stat.ctime.getTime(); if (timeDelta === 0) { // fall back to assumption of increasing inodes. I have no idea if // this is guaranteed, but think it is // If this is bad, then maybe this whole method should just use 'ls' // (delegate to the OS) since node isn't good enough here - return a.stat.ino - b.stat.ino + return a.stat.ino - b.stat.ino; } - return timeDelta + return timeDelta; }) - .map(({ name }) => this.filenameToKey(name)) - return sortedAscByCreation[Symbol.iterator]() + .map(({ name }) => this.filenameToKey(name)); + return sortedAscByCreation[Symbol.iterator](); } public values() { - return Array.from(this.keys()).map((file: string) => this.get(file))[Symbol.iterator]() + return Array.from(this.keys()) + .map((file: string) => this.get(file)) + [Symbol.iterator](); } public entries() { - return Array.from(this.keys()).map((file) => [file, this.get(file)] as [string, V])[Symbol.iterator]() + return Array.from(this.keys()) + .map(file => [file, this.get(file)] as [string, V]) + [Symbol.iterator](); } private keyToFilename(key: string): string { - if (typeof key !== "string") { key = JSON.stringify(key) } - return filenameEncoder.encode(key) + if (typeof key !== "string") { + key = JSON.stringify(key); + } + return filenameEncoder.encode(key); } private keyToPath(key: string): string { - const keyPath = path.join(this.dir, this.keyToFilename(key)) - return keyPath + const keyPath = path.join(this.dir, this.keyToFilename(key)); + return keyPath; } private keyToOldPath(key: string): string { - return path.join(this.dir, key) + return path.join(this.dir, key); } private filenameToKey(filename: string): string { - return filenameEncoder.decode(filename) + return filenameEncoder.decode(filename); } - private keyToExistentPath(key: string): string|void { + private keyToExistentPath(key: string): string | void { for (const pathToTry of [this.keyToPath(key), this.keyToOldPath(key)]) { - if (fs.existsSync(pathToTry)) { return pathToTry } + if (fs.existsSync(pathToTry)) { + return pathToTry; + } } } get size() { - return Array.from(this.keys()).length + return Array.from(this.keys()).length; } -} +}; -type AsyncMapKey = string -type AsyncMapValue = object +type AsyncMapKey = string; +type AsyncMapValue = object; export interface IAsyncMap<K, V> { size: Promise<number>; clear(): Promise<void>; delete(key: K): Promise<boolean>; - forEach(callbackfn: (value: V, index: K, map: Map<K, V>) => void, thisArg?: any): void; + forEach( + callbackfn: (value: V, index: K, map: Map<K, V>) => void, + thisArg?: any, + ): void; get(key: K): Promise<V>; has(key: K): Promise<boolean>; set(key: K, value?: V): Promise<IAsyncMap<K, V>>; @@ -122,129 +139,141 @@ export interface IAsyncMap<K, V> { // Like a Map, but all methods return a Promise class AsyncMap<K, V> implements IAsyncMap<K, V> { public async clear() { - return Map.prototype.clear.call(this) + return Map.prototype.clear.call(this); } public async delete(key: K) { - return Map.prototype.delete.call(this, key) + return Map.prototype.delete.call(this, key); } public forEach(callbackfn: (value: V, index: K, map: Map<K, V>) => void) { - return Map.prototype.forEach.call(this, callbackfn) + return Map.prototype.forEach.call(this, callbackfn); } public async get(key: K) { - return Map.prototype.get.call(this, key) + return Map.prototype.get.call(this, key); } public async has(key: K) { - return Map.prototype.has.call(this, key) + return Map.prototype.has.call(this, key); } public async set(key: K, value: V) { - return Map.prototype.set.call(this, key, value) + return Map.prototype.set.call(this, key, value); } public async entries() { - return Map.prototype.entries.call(this) + return Map.prototype.entries.call(this); } public async keys() { - return Map.prototype.keys.call(this) + return Map.prototype.keys.call(this); } public async values() { - return Map.prototype.values.call(this) + return Map.prototype.values.call(this); } get size() { return (async () => { - return Promise.resolve(Array.from(await this.keys()).length) - })() + return Promise.resolve(Array.from(await this.keys()).length); + })(); } } // Like JSONFileMap, but all methods return Promises of their values // and i/o is done async -export class JSONFileMapAsync extends AsyncMap<string, any> implements IAsyncMap<string, any> { +export class JSONFileMapAsync extends AsyncMap<string, any> + implements IAsyncMap<string, any> { constructor(private dir: string) { - super() + super(); } - public async ["set"](key: string, val: string|object) { + public async ["set"](key: string, val: string | object) { // coerce to string - const pathForKey = this.keyToExistentPath(key) || this.keyToPath(key) - const valString = typeof val === "string" ? val : JSON.stringify(val, null, 2) - await denodeify(fs.writeFile)(pathForKey, valString) - return this + const pathForKey = this.keyToExistentPath(key) || this.keyToPath(key); + const valString = + typeof val === "string" ? val : JSON.stringify(val, null, 2); + await denodeify(fs.writeFile)(pathForKey, valString); + return this; } public async ["get"](key: string) { - const pathForKey = this.keyToExistentPath(key) - if (!pathForKey) { return } - return JSON.parse(await denodeify(fs.readFile)(pathForKey, "utf8")) + const pathForKey = this.keyToExistentPath(key); + if (!pathForKey) { + return; + } + return JSON.parse(await denodeify(fs.readFile)(pathForKey, "utf8")); } public async ["delete"](key: string) { - const pathForKey = this.keyToExistentPath(key) + const pathForKey = this.keyToExistentPath(key); if (pathForKey) { - fs.unlinkSync(pathForKey) + fs.unlinkSync(pathForKey); } - return true + return true; } public [Symbol.iterator]() { - return this.keys().then((keys) => keys[Symbol.iterator]()) + return this.keys().then(keys => keys[Symbol.iterator]()); } public async has(key: string) { - const got = await this.get(key) - return Boolean(got) + const got = await this.get(key); + return Boolean(got); } public async keys() { - const dir = this.dir - const files = fs.readdirSync(dir) + const dir = this.dir; + const files = fs.readdirSync(dir); const sortedAscByCreation = files - .map((name) => { - const stat = fs.statSync(path.join(dir, name)) - return ({ name, stat }) + .map(name => { + const stat = fs.statSync(path.join(dir, name)); + return { name, stat }; }) .sort((a, b) => { - const timeDelta = a.stat.ctime.getTime() - b.stat.ctime.getTime() + const timeDelta = a.stat.ctime.getTime() - b.stat.ctime.getTime(); if (timeDelta === 0) { // fall back to assumption of increasing inodes. I have no idea if // this is guaranteed, but think it is // If this is bad, then maybe this whole method should just use 'ls' // (delegate to the OS) since node isn't good enough here - return a.stat.ino - b.stat.ino + return a.stat.ino - b.stat.ino; } - return timeDelta + return timeDelta; }) - .map(({ name }) => filenameEncoder.decode(name)) - return sortedAscByCreation[Symbol.iterator]() + .map(({ name }) => filenameEncoder.decode(name)); + return sortedAscByCreation[Symbol.iterator](); } public async values() { - const files = await this.keys() - const values = await Promise.all(Array.from(files).map((file) => this.get(file))) - return values[Symbol.iterator]() + const files = await this.keys(); + const values = await Promise.all( + Array.from(files).map(file => this.get(file)), + ); + return values[Symbol.iterator](); } public async entries() { - const files = await this.keys() - const entries = await Promise.all(Array.from(files).map(async (key) => { - return [key, await this.get(key)] as [string, any] - })) - const entriesIterator = entries[Symbol.iterator]() - return entriesIterator + const files = await this.keys(); + const entries = await Promise.all( + Array.from(files).map(async key => { + return [key, await this.get(key)] as [string, any]; + }), + ); + const entriesIterator = entries[Symbol.iterator](); + return entriesIterator; } private keyToFilename(key: string): string { - if (typeof key !== "string") { key = JSON.stringify(key) } - return filenameEncoder.encode(key) + if (typeof key !== "string") { + key = JSON.stringify(key); + } + return filenameEncoder.encode(key); } private keyToPath(key: string): string { - const keyPath = path.join(this.dir, this.keyToFilename(key)) - return keyPath + const keyPath = path.join(this.dir, this.keyToFilename(key)); + return keyPath; } private keyToOldPath(key: string): string { - return path.join(this.dir, key) + return path.join(this.dir, key); } private filenameToKey(filename: string): string { - return filenameEncoder.decode(filename) + return filenameEncoder.decode(filename); } - private keyToExistentPath(key: string): string|void { + private keyToExistentPath(key: string): string | void { for (const pathToTry of [this.keyToPath(key), this.keyToOldPath(key)]) { - if (fs.existsSync(pathToTry)) { return pathToTry } + if (fs.existsSync(pathToTry)) { + return pathToTry; + } } } get size() { - return Promise.resolve(this.keys()).then((files) => Array.from(files).length) + return Promise.resolve(this.keys()).then(files => Array.from(files).length); } } diff --git a/src/index.ts b/src/index.ts index ce9861d..6510dd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,44 @@ -import * as accepts from "accepts" -import * as assert from "assert" -import { IncomingMessage, ServerResponse } from "http" -import { v4 as createUUID } from "node-uuid" -import * as querystring from "querystring" -import * as url from "url" +import * as accepts from "accepts"; +import * as assert from "assert"; +import { IncomingMessage, ServerResponse } from "http"; +import { v4 as createUUID } from "node-uuid"; +import * as querystring from "querystring"; +import * as url from "url"; import { as2ObjectIsActivity, publicCollectionId, targetAndDeliver, -} from "./activitypub" +} from "./activitypub"; import { internalUrlRewriter } from "./distbin-html/url-rewriter"; -import { createLogger } from "./logger" -import { Activity, ActivityMap, ASObject, Extendable, - JSONLD, LDValue } from "./types" +import { createLogger } from "./logger"; +import { + Activity, + ActivityMap, + ASObject, + Extendable, + JSONLD, + LDValue, +} from "./types"; import { debuglog, ensureArray, first, flatten, - jsonld, jsonldAppend, readableToString, + jsonld, + jsonldAppend, + readableToString, requestMaxMemberCount, route, RoutePattern, RouteResponderFactory, -} from "./util" +} from "./util"; -const logger = createLogger("index") +const logger = createLogger("index"); -const owlSameAs = "http://www.w3.org/2002/07/owl#sameAs" +const owlSameAs = "http://www.w3.org/2002/07/owl#sameAs"; // given a non-uri activity id, return an activity URI -const uuidUri = (uuid: string) => `urn:uuid:${uuid}` +const uuidUri = (uuid: string) => `urn:uuid:${uuid}`; // Factory function for another node.http handler function that defines distbin's web logic // (routes requests to sub-handlers with common error handling) @@ -46,76 +54,132 @@ export default function distbin({ internalUrl, deliverToLocalhost = false, }: { - activities?: Map<string, object>, - inbox?: Map<string, object>, - inboxFilter?: (obj: ASObject) => Promise<boolean>, - externalUrl?: string, - internalUrl?: string, - deliverToLocalhost?: boolean, -}= {}) { + activities?: Map<string, object>; + inbox?: Map<string, object>; + inboxFilter?: (obj: ASObject) => Promise<boolean>; + externalUrl?: string; + internalUrl?: string; + deliverToLocalhost?: boolean; +} = {}) { return (req: IncomingMessage, res: ServerResponse) => { - externalUrl = externalUrl || `http://${req.headers.host}` - let handler = route(new Map<RoutePattern, RouteResponderFactory>([ - ["/", () => index], - ["/recent", () => recentHandler({ activities })], - ["/activitypub/inbox", () => inboxHandler({ activities, inbox, inboxFilter, externalUrl })], - ["/activitypub/outbox", () => outboxHandler({ activities, externalUrl, internalUrl, deliverToLocalhost })], - ["/activitypub/public/page", () => publicCollectionPageHandler({ activities, externalUrl })], - ["/activitypub/public", () => publicCollectionHandler({ activities, externalUrl })], - // /activities/{activityUuid}.{format} - [/^\/activities\/([^/]+?)(\.(.+))$/, - (activityUuid: string, _: string, format: string) => - activityWithExtensionHandler({ activities, activityUuid, format, externalUrl })], - // /activities/{activityUuid} - [/^\/activities\/([^/]+)$/, - (activityUuid: string) => activityHandler({ activities, activityUuid, externalUrl })], - [/^\/activities\/([^/]+)\/replies$/, - (activityUuid: string) => activityRepliesHandler({ activities, activityUuid, externalUrl })], - ]), req) + externalUrl = externalUrl || `http://${req.headers.host}`; + let handler = route( + new Map<RoutePattern, RouteResponderFactory>([ + ["/", () => index], + ["/recent", () => recentHandler({ activities })], + [ + "/activitypub/inbox", + () => inboxHandler({ activities, inbox, inboxFilter, externalUrl }), + ], + [ + "/activitypub/outbox", + () => + outboxHandler({ + activities, + deliverToLocalhost, + externalUrl, + internalUrl, + }), + ], + [ + "/activitypub/public/page", + () => publicCollectionPageHandler({ activities, externalUrl }), + ], + [ + "/activitypub/public", + () => publicCollectionHandler({ activities, externalUrl }), + ], + // /activities/{activityUuid}.{format} + [ + /^\/activities\/([^/]+?)(\.(.+))$/, + (activityUuid: string, _: string, format: string) => + activityWithExtensionHandler({ + activities, + activityUuid, + externalUrl, + format, + }), + ], + // /activities/{activityUuid} + [ + /^\/activities\/([^/]+)$/, + (activityUuid: string) => + activityHandler({ activities, activityUuid, externalUrl }), + ], + [ + /^\/activities\/([^/]+)\/replies$/, + (activityUuid: string) => + activityRepliesHandler({ activities, activityUuid, externalUrl }), + ], + ]), + req, + ); if (!handler) { - handler = errorHandler(404) + handler = errorHandler(404); } try { - return Promise.resolve(handler(req, res)).catch((err) => { - return errorHandler(500, err)(req, res) - }) + return Promise.resolve(handler(req, res)).catch(err => { + return errorHandler(500, err)(req, res); + }); } catch (err) { - return errorHandler(500, err)(req, res) + return errorHandler(500, err)(req, res); } - } + }; } function isHostedLocally(activityFreshFromStorage: Activity) { - return !activityFreshFromStorage.hasOwnProperty("url") + return !activityFreshFromStorage.hasOwnProperty("url"); } -type UrnUuid = string -type ExternalUrl = string -function externalizeActivityId(activityId: UrnUuid, externalUrl: ExternalUrl): ExternalUrl { - const uuidMatch = activityId.match(/^urn:uuid:([^$]+)$/) - if (!uuidMatch) { throw new Error(`Couldn't determine UUID for activity with id: ${activityId}`) } - const uuid = uuidMatch[1] - const activityUrl = url.resolve(externalUrl, "./activities/" + uuid) - return activityUrl +type UrnUuid = string; +type ExternalUrl = string; +function externalizeActivityId( + activityId: UrnUuid, + externalUrl: ExternalUrl, +): ExternalUrl { + const uuidMatch = activityId.match(/^urn:uuid:([^$]+)$/); + if (!uuidMatch) { + throw new Error( + `Couldn't determine UUID for activity with id: ${activityId}`, + ); + } + const uuid = uuidMatch[1]; + const activityUrl = url.resolve(externalUrl, "./activities/" + uuid); + return activityUrl; } // return a an extended version of provided activity with some extra metadata properties like 'inbox', 'url', 'replies' // if 'baseUrl' opt is provided, those extra properties will be absolute URLs, not relative -const locallyHostedActivity = (activity: Extendable<Activity>, { externalUrl }: {externalUrl: string}) => { +const locallyHostedActivity = ( + activity: Extendable<Activity>, + { externalUrl }: { externalUrl: string }, +) => { if (activity.url) { - debuglog("Unexpected .url property when processing activity assumed to be locally" + - "hosted\n" + JSON.stringify(activity)) - throw new Error("Unexpected .url property when processing activity assumed to be locally hosted") + debuglog( + "Unexpected .url property when processing activity assumed to be locally" + + "hosted\n" + + JSON.stringify(activity), + ); + throw new Error( + "Unexpected .url property when processing activity assumed to be locally hosted", + ); + } + const uuidMatch = activity.id.match(/^urn:uuid:([^$]+)$/); + if (!uuidMatch) { + throw new Error( + `Couldn't determine UUID for activity with id: ${activity.id}`, + ); } - const uuidMatch = activity.id.match(/^urn:uuid:([^$]+)$/) - if (!uuidMatch) { throw new Error(`Couldn't determine UUID for activity with id: ${activity.id}`) } - const uuid = uuidMatch[1] + const uuid = uuidMatch[1]; // Each activity should have an ActivityPub/LDN inbox where it can receive notifications. // TODO should this be an inbox specific to this activity? - const inboxUrl = url.resolve(externalUrl, "./activitypub/inbox") - const activityUrl = url.resolve(externalUrl, "./activities/" + uuid) - const repliesUrl = url.resolve(externalUrl, "./activities/" + uuid + "/replies") + const inboxUrl = url.resolve(externalUrl, "./activitypub/inbox"); + const activityUrl = url.resolve(externalUrl, "./activities/" + uuid); + const repliesUrl = url.resolve( + externalUrl, + "./activities/" + uuid + "/replies", + ); return Object.assign({}, activity, { [owlSameAs]: jsonldAppend(activity[owlSameAs], activity.id), id: externalizeActivityId(activity.id, externalUrl), @@ -123,333 +187,411 @@ const locallyHostedActivity = (activity: Extendable<Activity>, { externalUrl }: replies: repliesUrl, url: jsonldAppend(activity.url, activityUrl), uuid, - }) -} + }); +}; // get specific activity by id -function activityHandler({ activities, activityUuid, externalUrl}: { - activities: ActivityMap, - activityUuid: string, - externalUrl: ExternalUrl, +function activityHandler({ + activities, + activityUuid, + externalUrl, +}: { + activities: ActivityMap; + activityUuid: string; + externalUrl: ExternalUrl; }) { return async (req: IncomingMessage, res: ServerResponse) => { - const uri = uuidUri(activityUuid) - const activity = await Promise.resolve(activities.get(uri)) + const uri = uuidUri(activityUuid); + const activity = await Promise.resolve(activities.get(uri)); // #TODO: If the activity isn't addressed to the public, we should enforce access controls here. if (!activity) { - res.writeHead(404) - res.end("There is no activity " + uri) - return + res.writeHead(404); + res.end("There is no activity " + uri); + return; } // redirect to remote ones if we know a URL if (!isHostedLocally(activity)) { if (activity.url) { // see other res.writeHead(302, { - location: (ensureArray(activity.url).filter((u: any): u is string => typeof u === "string") as string[])[0], - }) - res.end(activity.url) - return + location: (ensureArray(activity.url).filter( + (u: any): u is string => typeof u === "string", + ) as string[])[0], + }); + res.end(activity.url); + return; } else { - res.writeHead(404) - res.end(`Activity ${activityUuid} has been seen before, but it's not canonically` + - `hosted here, and I can't seem to find it's canonical URL. Sorry.`) - return + res.writeHead(404); + res.end( + `Activity ${activityUuid} has been seen before, but it's not canonically` + + `hosted here, and I can't seem to find it's canonical URL. Sorry.`, + ); + return; } } // return the activity - const extendedActivity = locallyHostedActivity(activity, {externalUrl}) + const extendedActivity = locallyHostedActivity(activity, { externalUrl }); // woo its here res.writeHead(200, { "content-type": "application/json", - }) - res.end(JSON.stringify(extendedActivity, null, 2)) - } + }); + res.end(JSON.stringify(extendedActivity, null, 2)); + }; } -function activityWithExtensionHandler({ activities, activityUuid, format, externalUrl }: { - activities: ActivityMap, - activityUuid: string, - format: string, - externalUrl: ExternalUrl, +function activityWithExtensionHandler({ + activities, + activityUuid, + format, + externalUrl, +}: { + activities: ActivityMap; + activityUuid: string; + format: string; + externalUrl: ExternalUrl; }) { return async (req: IncomingMessage, res: ServerResponse) => { if (format !== "json") { - res.writeHead(404) - res.end("Unsupported activity extension ." + format) - return + res.writeHead(404); + res.end("Unsupported activity extension ." + format); + return; } - return activityHandler({ activities, activityUuid, externalUrl })(req, res) - } + return activityHandler({ activities, activityUuid, externalUrl })(req, res); + }; } -function activityRepliesHandler({ activities, activityUuid, externalUrl }: { - activities: ActivityMap, - activityUuid: string, - externalUrl: ExternalUrl, +function activityRepliesHandler({ + activities, + activityUuid, + externalUrl, +}: { + activities: ActivityMap; + activityUuid: string; + externalUrl: ExternalUrl; }) { return async (req: IncomingMessage, res: ServerResponse) => { - const uri = uuidUri(activityUuid) - const parentActivity = await Promise.resolve(activities.get(uri)) + const uri = uuidUri(activityUuid); + const parentActivity = await Promise.resolve(activities.get(uri)); // #TODO: If the parentActivity isn't addressed to the public, we should enforce access controls here. if (!parentActivity) { - res.writeHead(404) - res.end("There is no activity " + uri) - return + res.writeHead(404); + res.end("There is no activity " + uri); + return; } const replies = Array.from(await Promise.resolve(activities.entries())) .filter(([key, activity]) => { - if ( ! activity) { - logger.warn("activities map has a falsy value for key", key) - return false + if (!activity) { + logger.warn("activities map has a falsy value for key", key); + return false; } - type ParentId = string - const filteredReplies: ASObject[] = ensureArray<any>(activity.object).filter((o) => typeof o === "object") + type ParentId = string; + const filteredReplies: ASObject[] = ensureArray<any>( + activity.object, + ).filter(o => typeof o === "object"); const inReplyTos = flatten( filteredReplies.map((object: ASObject) => - ensureArray<any>(object.inReplyTo) - .map((o: any): ParentId => { - if (typeof o === "string") { return o } - if (o instanceof ASObject) { return o.id } - }), + ensureArray<any>(object.inReplyTo).map( + (o: any): ParentId => { + if (typeof o === "string") { + return o; + } + if (o instanceof ASObject) { + return o.id; + } + }, + ), ), - ).filter(Boolean) + ).filter(Boolean); return inReplyTos.some((inReplyTo: ParentId) => { // TODO .inReplyTo could be a urn, http URL, something else? - const pathNameReplyCandidate = url.parse(inReplyTo).pathname - const pathNameOfReply = url.parse(url.resolve(externalUrl, `./activities/${activityUuid}`)).pathname - const isReply = pathNameReplyCandidate === pathNameOfReply - return isReply - }) + const pathNameReplyCandidate = url.parse(inReplyTo).pathname; + const pathNameOfReply = url.parse( + url.resolve(externalUrl, `./activities/${activityUuid}`), + ).pathname; + const isReply = pathNameReplyCandidate === pathNameOfReply; + return isReply; + }); }) .map(([id, replyActivity]) => { if (isHostedLocally(replyActivity)) { - return locallyHostedActivity(replyActivity, {externalUrl}) + return locallyHostedActivity(replyActivity, { externalUrl }); } - return replyActivity - }) + return replyActivity; + }); res.writeHead(200, { "content-type": "application/json", - }) - res.end(JSON.stringify({ - // TODO: sort/paginate/limit this - items: replies, - name: "replies to item with UUID " + activityUuid, - totalItems: replies.length, - type: "Collection", - }, null, 2)) - } + }); + res.end( + JSON.stringify( + { + // TODO: sort/paginate/limit this + items: replies, + name: "replies to item with UUID " + activityUuid, + totalItems: replies.length, + type: "Collection", + }, + null, + 2, + ), + ); + }; } // root route, do nothing for now but 200 function index(req: IncomingMessage, res: ServerResponse) { res.writeHead(200, { "content-type": "application/json", - }) - res.end(JSON.stringify({ - "@context": [ - "https://www.w3.org/ns/activitystreams", + }); + res.end( + JSON.stringify( { - activitypub: "https://www.w3.org/ns/activitypub#", - inbox: "activitypub:inbox", - outbox: "activitypub:outbox", + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + activitypub: "https://www.w3.org/ns/activitypub#", + inbox: "activitypub:inbox", + outbox: "activitypub:outbox", + }, + ], + inbox: "/activitypub/inbox", + name: "distbin", + outbox: "/activitypub/outbox", + recent: "/recent", + summary: + "A public service to store and retrieve posts and enable " + + "(federated, standards-compliant) social interaction around them", + type: "Service", }, - ], - "inbox": "/activitypub/inbox", - "name": "distbin", - "outbox": "/activitypub/outbox", - "recent": "/recent", - "summary": "A public service to store and retrieve posts and enable " + - "(federated, standards-compliant) social interaction around them", - "type": "Service", - }, null, 2)) + null, + 2, + ), + ); } // fetch a collection of recent Activities/things -function recentHandler({ activities }: {activities: ActivityMap}) { +function recentHandler({ activities }: { activities: ActivityMap }) { return async (req: IncomingMessage, res: ServerResponse) => { - const maxMemberCount = requestMaxMemberCount(req) || 10 + const maxMemberCount = requestMaxMemberCount(req) || 10; res.writeHead(200, { "Access-Control-Allow-Origin": "*", "content-type": "application/json", - }) - res.end(JSON.stringify({ - "@context": "https://www.w3.org/ns/activitystreams", - // empty string is relative URL for 'self' - "current": "", - // Get recent 10 items - "items": [...(await Promise.resolve(activities.values()))].reverse().slice(-1 * maxMemberCount), - "summary": "Things that have recently been created", - "totalItems": await activities.size, - "type": "OrderedCollection", - }, null, 2)) - } + }); + res.end( + JSON.stringify( + { + "@context": "https://www.w3.org/ns/activitystreams", + // empty string is relative URL for 'self' + current: "", + // Get recent 10 items + items: [...(await Promise.resolve(activities.values()))] + .reverse() + .slice(-1 * maxMemberCount), + summary: "Things that have recently been created", + totalItems: await activities.size, + type: "OrderedCollection", + }, + null, + 2, + ), + ); + }; } // route for ActivityPub Inbox // https://w3c.github.io/activitypub/#inbox -function inboxHandler({ activities, externalUrl, inbox, inboxFilter }: { - activities: ActivityMap, - externalUrl: string, - inbox: ActivityMap, - inboxFilter?: (obj: ASObject) => Promise<boolean>, +function inboxHandler({ + activities, + externalUrl, + inbox, + inboxFilter, +}: { + activities: ActivityMap; + externalUrl: string; + inbox: ActivityMap; + inboxFilter?: (obj: ASObject) => Promise<boolean>; }) { return async (req: IncomingMessage, res: ServerResponse) => { switch (req.method.toLowerCase()) { case "options": res.writeHead(200, { "Accept-Post": [ - "application/activity+json", "application/json", "application/ld+json", + "application/activity+json", + "application/json", + "application/ld+json", 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', ].join(", "), - }) - res.end() - return + }); + res.end(); + return; case "get": - const idQuery = first(url.parse(req.url, true).query.id) - let responseBody + const idQuery = first(url.parse(req.url, true).query.id); + let responseBody; if (idQuery) { // trying to just get one notification - const itemWithId = await inbox.get(idQuery) + const itemWithId = await inbox.get(idQuery); if (!itemWithId) { - res.writeHead(404) - res.end() - return + res.writeHead(404); + res.end(); + return; } - responseBody = itemWithId + responseBody = itemWithId; } else { // getting a bunch of notifications - const maxMemberCount = requestMaxMemberCount(req) || 10 - const items = [...(await Promise.resolve(inbox.values()))].slice(-1 * maxMemberCount).reverse() + const maxMemberCount = requestMaxMemberCount(req) || 10; + const items = [...(await Promise.resolve(inbox.values()))] + .slice(-1 * maxMemberCount) + .reverse(); const inboxCollection = { "@context": "https://www.w3.org/ns/activitystreams", "@id": "/activitypub/inbox", // empty string is relative URL for 'self' - "current": "", + current: "", items, - "ldp:contains": items.map((i) => ({ id: i.id })).filter(Boolean), - "totalItems": await inbox.size, - "type": ["OrderedCollection", "ldp:Container"], - } - responseBody = inboxCollection + "ldp:contains": items.map(i => ({ id: i.id })).filter(Boolean), + totalItems: await inbox.size, + type: ["OrderedCollection", "ldp:Container"], + }; + responseBody = inboxCollection; } - const accept = accepts(req) + const accept = accepts(req); const serverPreferences = [ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', "json", "application/ld+json", "application/activity+json", - ] - const contentType = accept.type(serverPreferences) || serverPreferences[0] + ]; + const contentType = + accept.type(serverPreferences) || serverPreferences[0]; res.writeHead(200, { "content-type": contentType, - }) - res.end(JSON.stringify(responseBody, null, 2)) - break + }); + res.end(JSON.stringify(responseBody, null, 2)); + break; case "post": - debuglog("receiving inbox POST") - const requestBody = await readableToString(req) - debuglog(requestBody) - let parsed + debuglog("receiving inbox POST"); + const requestBody = await readableToString(req); + debuglog(requestBody); + let parsed; try { - parsed = JSON.parse(requestBody) + parsed = JSON.parse(requestBody); } catch (e) { - res.writeHead(400) - res.end("Couldn't parse request body as JSON: " + requestBody) - return + res.writeHead(400); + res.end("Couldn't parse request body as JSON: " + requestBody); + return; } - const inboxFilterResult = await inboxFilter(parsed) - if ( ! inboxFilterResult) { + const inboxFilterResult = await inboxFilter(parsed); + if (!inboxFilterResult) { res.writeHead(400); - res.end("This activity has been blocked by the configured inboxFilter") - return + res.end( + "This activity has been blocked by the configured inboxFilter", + ); + return; } - const existsAlreadyInInbox = parsed.id ? await Promise.resolve(inbox.get(parsed.id)) : false + const existsAlreadyInInbox = parsed.id + ? await Promise.resolve(inbox.get(parsed.id)) + : false; if (existsAlreadyInInbox) { // duplicate! - res.writeHead(409) - res.end("There is already an activity in the inbox with id " + parsed.id) - return + res.writeHead(409); + res.end( + "There is already an activity in the inbox with id " + parsed.id, + ); + return; } - const notificationToSave = Object.assign({}, parsed) - const compacted = await jsonld.compact(notificationToSave, {}) - let originalId = compacted["@id"] + const notificationToSave = Object.assign({}, parsed); + const compacted = await jsonld.compact(notificationToSave, {}); + let originalId = compacted["@id"]; // Move incomding @id to wasDerivedFrom, then provision a new @id if (originalId) { - notificationToSave["http://www.w3.org/ns/prov#wasDerivedFrom"] = { id: originalId } + notificationToSave["http://www.w3.org/ns/prov#wasDerivedFrom"] = { + id: originalId, + }; } else { // can't understand parsed's id - delete parsed["@id"] - delete parsed.id - parsed.id = originalId = uuidUri(createUUID()) + delete parsed["@id"]; + delete parsed.id; + parsed.id = originalId = uuidUri(createUUID()); } - delete notificationToSave["@id"] - const notificationUrnUuid = uuidUri(createUUID()) - const notificationUrl = `/activitypub/inbox?id=${encodeURIComponent(notificationUrnUuid)}` + delete notificationToSave["@id"]; + const notificationUrnUuid = uuidUri(createUUID()); + const notificationUrl = `/activitypub/inbox?id=${encodeURIComponent( + notificationUrnUuid, + )}`; - notificationToSave.id = notificationUrl + notificationToSave.id = notificationUrl; - notificationToSave[owlSameAs] = { id: notificationUrnUuid } + notificationToSave[owlSameAs] = { id: notificationUrnUuid }; // If receiving a notification about an activity we've seen before (e.g. it is canonically hosted here), // this will be true - const originalIdsIncludingSameAs = [originalId, ...ensureArray(compacted[owlSameAs])] - const originalIdsHave = await Promise.all(originalIdsIncludingSameAs.map((aid) => activities.has(aid))) - const originalAlreadySaved = originalIdsHave.some(Boolean) + const originalIdsIncludingSameAs = [ + originalId, + ...ensureArray(compacted[owlSameAs]), + ]; + const originalIdsHave = await Promise.all( + originalIdsIncludingSameAs.map(aid => activities.has(aid)), + ); + const originalAlreadySaved = originalIdsHave.some(Boolean); if (originalAlreadySaved) { // #TODO merge or something? Consider storing local ones and remote ones in different places - debuglog("Inbox received activity already stored in activities store." + - "Not overwriting internal one. But #TODO") + debuglog( + "Inbox received activity already stored in activities store." + + "Not overwriting internal one. But #TODO", + ); } - assert(originalId) + assert(originalId); await Promise.all([ inbox.set(notificationUrnUuid, notificationToSave), // todo: Probably setting on inbox should automagically add to global set of activities originalAlreadySaved ? null : activities.set(originalId, parsed), - ]) + ]); res.writeHead(201, { location: notificationUrl, - }) - res.end() - break + }); + res.end(); + break; default: - return errorHandler(405, new Error("Method not allowed: "))(req, res) + return errorHandler(405, new Error("Method not allowed: "))(req, res); } - } + }; } // given a AS2 object, return it's JSON-LD @id -const getJsonLdId = (obj: string|ASObject|JSONLD) => { +const getJsonLdId = (obj: string | ASObject | JSONLD) => { if (typeof obj === "string") { - return obj + return obj; } else if (obj instanceof JSONLD) { - return obj["@id"] + return obj["@id"]; } else if (obj instanceof ASObject) { - return obj.id + return obj.id; } else { const exhaustiveCheck: never = obj; } -} +}; // return whether a given activity targets another resource (e.g. in to, cc, bcc) const activityHasTarget = (activity: Activity, target: LDValue<ASObject>) => { - const targetId = getJsonLdId(target) + const targetId = getJsonLdId(target); if (!targetId) { - throw new Error("Couldn't determine @id of " + target) + throw new Error("Couldn't determine @id of " + target); } for (const targetList of [activity.to, activity.cc, activity.bcc]) { - if (!targetList) { continue } - const targets = ensureArray<string|ASObject>(targetList) - const idsOfTargets = targets.map((i: string|ASObject) => getJsonLdId(i)) - if (idsOfTargets.includes(targetId)) { return true } + if (!targetList) { + continue; + } + const targets = ensureArray<string | ASObject>(targetList); + const idsOfTargets = targets.map((i: string | ASObject) => getJsonLdId(i)); + if (idsOfTargets.includes(targetId)) { + return true; + } } - return false -} + return false; +}; // route for ActivityPub Outbox // https://w3c.github.io/activitypub/#outbox @@ -459,47 +601,65 @@ function outboxHandler({ externalUrl, internalUrl, deliverToLocalhost, -}: {activities: ActivityMap, externalUrl: string, internalUrl: string, deliverToLocalhost: boolean}) { +}: { + activities: ActivityMap; + externalUrl: string; + internalUrl: string; + deliverToLocalhost: boolean; +}) { return async (req: IncomingMessage, res: ServerResponse) => { switch (req.method.toLowerCase()) { case "get": res.writeHead(200, { "content-type": "application/json", - }) - res.end(JSON.stringify({ - "@context": "https://www.w3.org/ns/activitystreams", - "items": [], - "type": "OrderedCollection", - }, null, 2)) - break + }); + res.end( + JSON.stringify( + { + "@context": "https://www.w3.org/ns/activitystreams", + items: [], + type: "OrderedCollection", + }, + null, + 2, + ), + ); + break; case "post": - const requestBody = await readableToString(req) - const newuuid = createUUID() - let parsed: { [key: string]: any } + const requestBody = await readableToString(req); + const newuuid = createUUID(); + let parsed: { [key: string]: any }; try { - parsed = JSON.parse(requestBody) + parsed = JSON.parse(requestBody); } catch (e) { - res.writeHead(400) - res.end("Couldn't parse request body as JSON: " + requestBody) - return + res.writeHead(400); + res.end("Couldn't parse request body as JSON: " + requestBody); + return; } // https://w3c.github.io/activitypub/#object-without-create // The server must accept a valid [ActivityStreams] object that isn't a subtype // of Activity in the POST request to the outbox. // The server then must attach this object as the object of a Create Activity. - const submittedActivity = as2ObjectIsActivity(parsed) ? parsed : Object.assign( - { - "@context": "https://www.w3.org/ns/activitystreams", - "object": parsed, - "type": "Create", - }, - // copy over audience from submitted object to activity - ["to", "cc", "bcc"].reduce((props: {[key: string]: any}, key) => { - if (key in parsed) { props[key] = parsed[key] } - return props - }, {}), - ) + const submittedActivity = as2ObjectIsActivity(parsed) + ? parsed + : Object.assign( + { + "@context": "https://www.w3.org/ns/activitystreams", + object: parsed, + type: "Create", + }, + // copy over audience from submitted object to activity + ["to", "cc", "bcc"].reduce( + (props: { [key: string]: any }, key) => { + if (key in parsed) { + props[key] = parsed[key]; + } + return props; + }, + {}, + ), + ); const newActivity = Object.assign( { @@ -510,7 +670,7 @@ function outboxHandler({ // #TODO: validate that newActivity wasn't submitted with an .id, even though spec says to rewrite it id: uuidUri(newuuid), // #TODO: what if it already had published? - published: (new Date()).toISOString(), + published: new Date().toISOString(), }, typeof submittedActivity.object === "object" && { // ensure object has id @@ -519,253 +679,304 @@ function outboxHandler({ submittedActivity.object, ), }, - ) + ); // #TODO: validate the activity. Like... you probably shouldn't be able to just send '{}' - const location = "/activities/" + newuuid + const location = "/activities/" + newuuid; // Save - await activities.set(newActivity.id, newActivity) + await activities.set(newActivity.id, newActivity); - res.writeHead(201, { location }) + res.writeHead(201, { location }); try { // Target and Deliver to other inboxes - const activityToDeliver = locallyHostedActivity(newActivity, { externalUrl }) + const activityToDeliver = locallyHostedActivity(newActivity, { + externalUrl, + }); await targetAndDeliver( - activityToDeliver, undefined, deliverToLocalhost, internalUrlRewriter(internalUrl, externalUrl)) + activityToDeliver, + undefined, + deliverToLocalhost, + internalUrlRewriter(internalUrl, externalUrl), + ); } catch (e) { - debuglog("Error delivering activity other inboxes", e) + debuglog("Error delivering activity other inboxes", e); if (e.name === "SomeDeliveriesFailed") { const failures = e.failures.map((f: Error) => { return { message: f.message, name: f.name, - } - }) + }; + }); // #TODO: Retry some day - res.end(JSON.stringify({ - content: "Activity was created, but delivery to some others servers'" + - "inbox failed. They will not be retried.", - failures, - })) - await activities.set(newActivity.id, Object.assign({}, newActivity, { - "distbin:activityPubDeliveryFailures": failures, - "distbin:activityPubDeliverySuccesses": e.successes, - })) - return + res.end( + JSON.stringify({ + content: + "Activity was created, but delivery to some others servers'" + + "inbox failed. They will not be retried.", + failures, + }), + ); + await activities.set( + newActivity.id, + Object.assign({}, newActivity, { + "distbin:activityPubDeliveryFailures": failures, + "distbin:activityPubDeliverySuccesses": e.successes, + }), + ); + return; } - throw e + throw e; } - res.end() - break + res.end(); + break; default: - return errorHandler(405, new Error("Method not allowed: "))(req, res) + return errorHandler(405, new Error("Method not allowed: "))(req, res); } - } + }; } // route for ActivityPub Public Collection // https://w3c.github.io/activitypub/#public-addressing -function publicCollectionHandler({ activities, externalUrl }: { - activities: ActivityMap, - externalUrl: ExternalUrl, +function publicCollectionHandler({ + activities, + externalUrl, +}: { + activities: ActivityMap; + externalUrl: ExternalUrl; }) { return async (req: IncomingMessage, res: ServerResponse) => { - const maxMemberCount = requestMaxMemberCount(req) || 10 - const publicActivities = [] - const itemsForThisPage = [] - const allActivities = [...await Promise.resolve(activities.values())].sort((a, b) => { - if (a.published < b.published) { - return -1 - } else if (a.published > b.published) { - return 1 - } else { - // assume ids aren't equal. If so we have a bigger problem - return (a.id < b.id) ? -1 : 1 - } - }).reverse() + const maxMemberCount = requestMaxMemberCount(req) || 10; + const publicActivities = []; + const itemsForThisPage = []; + const allActivities = [...(await Promise.resolve(activities.values()))] + .sort((a, b) => { + if (a.published < b.published) { + return -1; + } else if (a.published > b.published) { + return 1; + } else { + // assume ids aren't equal. If so we have a bigger problem + return a.id < b.id ? -1 : 1; + } + }) + .reverse(); for (const activity of allActivities) { - if (!activityHasTarget(activity, publicCollectionId)) { continue } - publicActivities.push(activity) - if (itemsForThisPage.length < maxMemberCount) { itemsForThisPage.push(activity) } + if (!activityHasTarget(activity, publicCollectionId)) { + continue; + } + publicActivities.push(activity); + if (itemsForThisPage.length < maxMemberCount) { + itemsForThisPage.push(activity); + } } - const currentItems = itemsForThisPage.map((activity) => { + const currentItems = itemsForThisPage.map(activity => { if (isHostedLocally(activity)) { - return locallyHostedActivity(activity, {externalUrl}) + return locallyHostedActivity(activity, { externalUrl }); } - return activity - }) - const totalItems = publicActivities.length + return activity; + }); + const totalItems = publicActivities.length; // empty string is relative URL for 'self' - const currentUrl = [req.url, req.url.endsWith("/") ? "" : "/", "page"].join("") + const currentUrl = [req.url, req.url.endsWith("/") ? "" : "/", "page"].join( + "", + ); const publicCollection = { "@context": "https://www.w3.org/ns/activitystreams", - "current": { + current: { href: currentUrl, mediaType: "application/json", name: "Recently updated public activities", rel: "current", type: "Link", }, - "first": currentUrl, - "id": "https://www.w3.org/ns/activitypub/Public", + first: currentUrl, + id: "https://www.w3.org/ns/activitypub/Public", // Get recent 10 items - "items": currentItems, - "totalItems": totalItems, - "type": "Collection", - } + items: currentItems, + totalItems, + type: "Collection", + }; res.writeHead(200, { "content-type": "application/json", - }) - res.end(JSON.stringify(publicCollection, null, 2)) - } + }); + res.end(JSON.stringify(publicCollection, null, 2)); + }; } interface IPropertyFilter { - readonly [key: string]: Comparison + readonly [key: string]: Comparison; } interface IAndExpression { - and: Filter[] + and: Filter[]; } -function isAndExpression(expression: object): expression is IAndExpression { // magic happens here +function isAndExpression(expression: object): expression is IAndExpression { + // magic happens here return (expression as IAndExpression).and !== undefined; } interface IOrExpression { - or: Filter[] + or: Filter[]; } -function isOrExpression(expression: object): expression is IOrExpression { // magic happens here +function isOrExpression(expression: object): expression is IOrExpression { + // magic happens here return (expression as IOrExpression).or !== undefined; } -type CompoundFilter = IAndExpression | IOrExpression +type CompoundFilter = IAndExpression | IOrExpression; function isCompoundFilter(filter: object): filter is CompoundFilter { - return isAndExpression(filter) || isOrExpression(filter) + return isAndExpression(filter) || isOrExpression(filter); } -type Filter = IPropertyFilter | CompoundFilter +type Filter = IPropertyFilter | CompoundFilter; -type FilterComparison = "lt" | "equals" +type FilterComparison = "lt" | "equals"; -type Cursor = CompoundFilter +type Cursor = CompoundFilter; interface ILessThanComparison { - lt: string + lt: string; } -function isLessThanComparison(comparison: object): comparison is ILessThanComparison { - return Boolean((comparison as ILessThanComparison).lt) +function isLessThanComparison( + comparison: object, +): comparison is ILessThanComparison { + return Boolean((comparison as ILessThanComparison).lt); } interface IEqualsComparison { - equals: string + equals: string; } -function isEqualsComparison(comparison: object): comparison is IEqualsComparison { - return Boolean((comparison as IEqualsComparison).equals) +function isEqualsComparison( + comparison: object, +): comparison is IEqualsComparison { + return Boolean((comparison as IEqualsComparison).equals); } -type Comparison = ILessThanComparison | IEqualsComparison +type Comparison = ILessThanComparison | IEqualsComparison; function isComparison(comparison: object): comparison is Comparison { - return Boolean((comparison as ILessThanComparison).lt || (comparison as IEqualsComparison).equals) + return Boolean( + (comparison as ILessThanComparison).lt || + (comparison as IEqualsComparison).equals, + ); } function getClauses(expression: CompoundFilter): Filter[] { if (isAndExpression(expression)) { - return expression.and + return expression.and; } else if (isOrExpression(expression)) { - return expression.or + return expression.or; } } -const createMatchesCursor = (cursor: CompoundFilter) => (activity: Extendable<Activity>) => { - assert.equal(Object.keys(cursor).length, 1) - const clauses: Filter[] = getClauses(cursor) || [] +const createMatchesCursor = (cursor: CompoundFilter) => ( + activity: Extendable<Activity>, +) => { + assert.equal(Object.keys(cursor).length, 1); + const clauses: Filter[] = getClauses(cursor) || []; for (const filter of clauses) { - assert.equal(Object.keys(filter).length, 1) - const prop = Object.keys(filter)[0] - let matchesRequirement: boolean + assert.equal(Object.keys(filter).length, 1); + const prop = Object.keys(filter)[0]; + let matchesRequirement: boolean; // if (prop instanceof IAndExpression | IOrExpression | EqualsExpression) { if (isCompoundFilter(filter)) { - const compoundFilter: CompoundFilter = filter + const compoundFilter: CompoundFilter = filter; // this is another expression, recurse - matchesRequirement = createMatchesCursor(compoundFilter)(activity) + matchesRequirement = createMatchesCursor(compoundFilter)(activity); } else { - const IpropertyFilter: IPropertyFilter = filter - const comparison: Comparison = IpropertyFilter[prop] - const propValue = activity[prop] + const IpropertyFilter: IPropertyFilter = filter; + const comparison: Comparison = IpropertyFilter[prop]; + const propValue = activity[prop]; if (isLessThanComparison(comparison)) { - matchesRequirement = propValue < comparison.lt + matchesRequirement = propValue < comparison.lt; } else if (isEqualsComparison(comparison)) { - matchesRequirement = propValue === comparison.equals + matchesRequirement = propValue === comparison.equals; } } if (matchesRequirement && isOrExpression(cursor)) { - return true + return true; } - if ((!matchesRequirement) && isAndExpression(cursor)) { - return false + if (!matchesRequirement && isAndExpression(cursor)) { + return false; } } - if (isOrExpression(cursor)) { return false } - if (isAndExpression(cursor)) { return true } -} + if (isOrExpression(cursor)) { + return false; + } + if (isAndExpression(cursor)) { + return true; + } +}; -function publicCollectionPageHandler({ activities, externalUrl }: { - activities: Map<string, Activity>, - externalUrl: ExternalUrl, +function publicCollectionPageHandler({ + activities, + externalUrl, +}: { + activities: Map<string, Activity>; + externalUrl: ExternalUrl; }) { return async (req: IncomingMessage, res: ServerResponse) => { - const maxMemberCount = requestMaxMemberCount(req) || 10 - const parsedUrl = url.parse(req.url, true) - let cursor - let matchesCursor = (a: Activity) => true + const maxMemberCount = requestMaxMemberCount(req) || 10; + const parsedUrl = url.parse(req.url, true); + let cursor; + let matchesCursor = (a: Activity) => true; if (parsedUrl.query.cursor) { try { - cursor = JSON.parse(first(parsedUrl.query.cursor)) + cursor = JSON.parse(first(parsedUrl.query.cursor)); } catch (error) { - res.writeHead(400) - res.end(JSON.stringify({ message: "Invalid cursor in querystring" })) - return + res.writeHead(400); + res.end(JSON.stringify({ message: "Invalid cursor in querystring" })); + return; } - matchesCursor = createMatchesCursor(cursor) + matchesCursor = createMatchesCursor(cursor); } - const publicActivities = [] - const itemsForThisPage = [] + const publicActivities = []; + const itemsForThisPage = []; // @todo ensure sorted by both published and id - const allActivities = [...await Promise.resolve(activities.values())].sort((a, b) => { - if (a.published < b.published) { return -1 } else if (a.published > b.published) { return 1 } else { - // assume ids aren't equal. If so we have a bigger problem - return (a.id < b.id) ? -1 : 1 - } - }).reverse() - let itemsBeforeCursor = 0 + const allActivities = [...(await Promise.resolve(activities.values()))] + .sort((a, b) => { + if (a.published < b.published) { + return -1; + } else if (a.published > b.published) { + return 1; + } else { + // assume ids aren't equal. If so we have a bigger problem + return a.id < b.id ? -1 : 1; + } + }) + .reverse(); + let itemsBeforeCursor = 0; for (const activity of allActivities) { - if (!activityHasTarget(activity, publicCollectionId)) { continue } - publicActivities.push(activity) + if (!activityHasTarget(activity, publicCollectionId)) { + continue; + } + publicActivities.push(activity); if (!matchesCursor(activity)) { - itemsBeforeCursor++ - continue + itemsBeforeCursor++; + continue; + } + if (itemsForThisPage.length < maxMemberCount) { + itemsForThisPage.push(activity); } - if (itemsForThisPage.length < maxMemberCount) { itemsForThisPage.push(activity) } } - const currentItems = itemsForThisPage.map((activity) => { + const currentItems = itemsForThisPage.map(activity => { if (isHostedLocally(activity)) { - return locallyHostedActivity(activity, {externalUrl}) + return locallyHostedActivity(activity, { externalUrl }); } - return activity - }) - const totalItems = publicActivities.length - let next + return activity; + }); + const totalItems = publicActivities.length; + let next; if (totalItems > currentItems.length) { - const lastItem = currentItems[currentItems.length - 1] + const lastItem = currentItems[currentItems.length - 1]; if (lastItem) { const nextCursor = JSON.stringify({ or: [ @@ -777,32 +988,32 @@ function publicCollectionPageHandler({ activities, externalUrl }: { ], }, ], - }) - next = "?" + querystring.stringify({ cursor: nextCursor }) + }); + next = "?" + querystring.stringify({ cursor: nextCursor }); } } const collectionPage = { "@context": "https://www.w3.org/ns/activitystreams", next, - "orderedItems": currentItems, - "partOf": "/activitypub/public", - "startIndex": itemsBeforeCursor, - "type": "OrderedCollectionPage", - } + orderedItems: currentItems, + partOf: "/activitypub/public", + startIndex: itemsBeforeCursor, + type: "OrderedCollectionPage", + }; res.writeHead(200, { "content-type": "application/json", - }) - res.end(JSON.stringify(collectionPage, null, 2)) - } + }); + res.end(JSON.stringify(collectionPage, null, 2)); + }; } function errorHandler(statusCode: number, error?: Error) { if (error) { - logger.error("", error) + logger.error("", error); } return (req: IncomingMessage, res: ServerResponse) => { - res.writeHead(statusCode) - const responseText = error ? error.toString() : statusCode.toString() - res.end(responseText) - } + res.writeHead(statusCode); + const responseText = error ? error.toString() : statusCode.toString(); + res.end(responseText); + }; } diff --git a/src/logger.ts b/src/logger.ts index f404b3c..453579a 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,18 +1,18 @@ // @ts-check -import * as winston from "winston" +import * as winston from "winston"; -const defaultName = "distbin" +const defaultName = "distbin"; export const createLogger = function getLogger(name: string) { - const logger = new (winston.Logger)({ + const logger = new winston.Logger({ transports: [ - new (winston.transports.Console)({ + new winston.transports.Console({ label: [defaultName, name].filter(Boolean).join("."), }), ], - }) + }); if (process.env.LOG_LEVEL) { - logger.level = process.env.LOG_LEVEL + logger.level = process.env.LOG_LEVEL; } - return logger -} + return logger; +}; diff --git a/src/types.ts b/src/types.ts index 96db8e5..0a8539e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,43 +1,67 @@ import { IncomingMessage, ServerResponse } from "http"; -import { Activity, ASLink, ASObject, Collection, isActivity, JSONLD, LDObject, LDValue, - LDValues, Place } from "./activitystreams/types"; -export { Collection, Place, LDValue, LDValues, LDObject, JSONLD, ASLink, ASObject, Activity, isActivity } +import { + Activity, + ASLink, + ASObject, + Collection, + isActivity, + JSONLD, + LDObject, + LDValue, + LDValues, + Place, +} from "./activitystreams/types"; +export { + Collection, + Place, + LDValue, + LDValues, + LDObject, + JSONLD, + ASLink, + ASObject, + Activity, + isActivity, +}; -type ISO8601 = string +type ISO8601 = string; -export type HttpRequestResponder = (req: IncomingMessage, res: ServerResponse) => void; +export type HttpRequestResponder = ( + req: IncomingMessage, + res: ServerResponse, +) => void; export type Extendable<T> = T & { - [key: string]: any, -} + [key: string]: any; +}; // extra fields used by distbin export class DistbinActivity extends Activity { - public "http://www.w3.org/ns/prov#wasDerivedFrom"?: LDValue<object> - public "distbin:activityPubDeliveryFailures"?: Error[] + public "http://www.w3.org/ns/prov#wasDerivedFrom"?: LDValue<object>; + public "distbin:activityPubDeliveryFailures"?: Error[]; } -export type ActivityMap = Map<string, Activity|DistbinActivity> +export type ActivityMap = Map<string, Activity | DistbinActivity>; -type mediaType = string +type mediaType = string; export class LinkPrefetchResult { - public type: string - public link: ASLink + public type: string; + public link: ASLink; constructor(props: any) { - this.type = this.constructor.name - Object.assign(this, props) + this.type = this.constructor.name; + Object.assign(this, props); } } export class LinkPrefetchSuccess extends LinkPrefetchResult { - public type: "LinkPrefetchSuccess" - public published: ISO8601 - public supportedMediaTypes: mediaType[] + public type: "LinkPrefetchSuccess"; + public published: ISO8601; + public supportedMediaTypes: mediaType[]; } export class LinkPrefetchFailure extends LinkPrefetchResult { - public type: "LinkPrefetchFailure" + public type: "LinkPrefetchFailure"; public error: { - status?: number - message: string, - } + status?: number; + message: string; + }; } export class HasLinkPrefetchResult { public "https://distbin.com/ns/linkPrefetch"?: LinkPrefetchResult; diff --git a/src/util.ts b/src/util.ts index 2138702..ca18cd9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,22 +1,22 @@ /// <reference types="node" /> // tslint:disable:no-var-requires -const jsonldRdfaParser = require("jsonld-rdfa-parser") -const jsonldLib = require("jsonld") +const jsonldRdfaParser = require("jsonld-rdfa-parser"); +const jsonldLib = require("jsonld"); // tslint:enable:no-var-requires -jsonldLib.registerRDFParser("text/html", jsonldRdfaParser) +jsonldLib.registerRDFParser("text/html", jsonldRdfaParser); import * as assert from "assert"; import * as fs from "fs"; -import {ClientRequestArgs} from "http"; +import { ClientRequestArgs } from "http"; import * as http from "http"; import * as https from "https"; import * as path from "path"; -import * as url from "url" -import { Url, UrlObject } from "url" +import * as url from "url"; +import { Url, UrlObject } from "url"; import * as util from "util"; -import {ASLink, HttpRequestResponder} from "./types"; +import { ASLink, HttpRequestResponder } from "./types"; -import { createLogger } from "../src/logger" -const logger = createLogger("util") +import { createLogger } from "../src/logger"; +const logger = createLogger("util"); /** * Return the 'first' item of the provided itemOrList. @@ -24,101 +24,132 @@ const logger = createLogger("util") * if itemOrList is not a collection, return itself */ export const first = (itemOrList: any) => { - if (Array.isArray(itemOrList)) { return itemOrList[0] } - return itemOrList -} + if (Array.isArray(itemOrList)) { + return itemOrList[0]; + } + return itemOrList; +}; -export const request = (urlOrOptions: string|UrlObject) => { - const options = typeof urlOrOptions === "string" ? url.parse(urlOrOptions) : urlOrOptions; +export const request = (urlOrOptions: string | UrlObject) => { + const options = + typeof urlOrOptions === "string" ? url.parse(urlOrOptions) : urlOrOptions; switch (options.protocol) { case "https:": - return https.request(urlOrOptions) + return https.request(urlOrOptions); case "http:": - return http.request(urlOrOptions) + return http.request(urlOrOptions); default: - throw new Error(`cannot create request for protocol ${options.protocol}`) + throw new Error(`cannot create request for protocol ${options.protocol}`); } -} +}; -export const debuglog = util.debuglog("distbin") +export const debuglog = util.debuglog("distbin"); -export const readableToString = (readable: NodeJS.ReadableStream): Promise<string> => { - let body: string = "" +export const readableToString = ( + readable: NodeJS.ReadableStream, +): Promise<string> => { + let body: string = ""; return new Promise((resolve, reject) => { - readable.on("error", reject) + readable.on("error", reject); readable.on("data", (chunk: string) => { - body += chunk - return body - }) - readable.on("end", () => resolve(body)) - }) -} + body += chunk; + return body; + }); + readable.on("end", () => resolve(body)); + }); +}; -export const requestUrl = (req: http.ServerRequest) => `http://${req.headers.host}${req.url}` +export const requestUrl = (req: http.ServerRequest) => + `http://${req.headers.host}${req.url}`; // given a map of strings/regexes to listener factories, // return a matching route (or undefined if no match) -export type RoutePattern = string | RegExp -export type RouteResponderFactory = (...matches: string[]) => HttpRequestResponder -export const route = (routes: Map<RoutePattern, RouteResponderFactory>, - req: http.ServerRequest) => { - const pathname = url.parse(req.url).pathname +export type RoutePattern = string | RegExp; +export type RouteResponderFactory = ( + ...matches: string[] +) => HttpRequestResponder; +export const route = ( + routes: Map<RoutePattern, RouteResponderFactory>, + req: http.ServerRequest, +) => { + const pathname = url.parse(req.url).pathname; for (const [routePathname, createHandler] of routes.entries()) { if (typeof routePathname === "string") { // exact match - if (pathname !== routePathname) { continue } - return createHandler() + if (pathname !== routePathname) { + continue; + } + return createHandler(); } if (routePathname instanceof RegExp) { - const match = pathname.match(routePathname) - if (!match) { continue } - return createHandler(...match.slice(1)) + const match = pathname.match(routePathname); + if (!match) { + continue; + } + return createHandler(...match.slice(1)); } } -} +}; -export const sendRequest = (r: http.ClientRequest): Promise<http.IncomingMessage> => { +export const sendRequest = ( + r: http.ClientRequest, +): Promise<http.IncomingMessage> => { return new Promise((resolve, reject) => { - r.once("response", resolve) - r.once("error", reject) - r.end() - }) -} + r.once("response", resolve); + r.once("error", reject); + r.end(); + }); +}; -export async function followRedirects(requestOpts: ClientRequestArgs, maxRedirects= 5) { - let redirectsLeft = maxRedirects - const initialUrl = url.format(requestOpts) - const latestUrl = initialUrl - assert(latestUrl) - logger.silly("followRedirects", latestUrl) +export async function followRedirects( + requestOpts: ClientRequestArgs, + maxRedirects = 5, +) { + let redirectsLeft = maxRedirects; + const initialUrl = url.format(requestOpts); + const latestUrl = initialUrl; + assert(latestUrl); + logger.silly("followRedirects", latestUrl); - let latestResponse = await sendRequest(request(requestOpts)) + let latestResponse = await sendRequest(request(requestOpts)); /* eslint-disable no-labels */ followRedirects: while (redirectsLeft > 0) { - logger.debug("followRedirects got response", { statusCode: latestResponse.statusCode }) + logger.debug("followRedirects got response", { + statusCode: latestResponse.statusCode, + }); switch (latestResponse.statusCode) { case 301: case 302: - const nextUrl = url.resolve(latestUrl, ensureArray(latestResponse.headers.location)[0]) - logger.debug("followRedirects is following to", nextUrl) - latestResponse = await sendRequest(request(Object.assign(url.parse(nextUrl), { - headers: requestOpts.headers, - }))) - redirectsLeft-- - continue followRedirects + const nextUrl = url.resolve( + latestUrl, + ensureArray(latestResponse.headers.location)[0], + ); + logger.debug("followRedirects is following to", nextUrl); + latestResponse = await sendRequest( + request( + Object.assign(url.parse(nextUrl), { + headers: requestOpts.headers, + }), + ), + ); + redirectsLeft--; + continue followRedirects; default: - return latestResponse + return latestResponse; } } - throw Object.assign(new Error(`Max redirects reached when requesting ${initialUrl}`), { - redirects: maxRedirects - redirectsLeft, - response: latestResponse, - }) + throw Object.assign( + new Error(`Max redirects reached when requesting ${initialUrl}`), + { + redirects: maxRedirects - redirectsLeft, + response: latestResponse, + }, + ); } -const SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g +const SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; // Match everything outside of normal chars and " (quote character) -const NON_ALPHANUMERIC_REGEXP = /([^#-~| |!])/g +const NON_ALPHANUMERIC_REGEXP = /([^#-~| |!])/g; /** * Escapes all potentially dangerous characters, so that the * resulting string can be safely inserted into attribute or @@ -129,88 +160,104 @@ const NON_ALPHANUMERIC_REGEXP = /([^#-~| |!])/g export const encodeHtmlEntities = function encodeEntities(value: string) { return value .replace(/&/g, "&") - .replace(SURROGATE_PAIR_REGEXP, (match) => { - const hi = match.charCodeAt(0) - const low = match.charCodeAt(1) - return "&#" + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ";" + .replace(SURROGATE_PAIR_REGEXP, match => { + const hi = match.charCodeAt(0); + const low = match.charCodeAt(1); + return "&#" + ((hi - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000) + ";"; }) - .replace(NON_ALPHANUMERIC_REGEXP, (match) => { - return "&#" + match.charCodeAt(0) + ";" + .replace(NON_ALPHANUMERIC_REGEXP, match => { + return "&#" + match.charCodeAt(0) + ";"; }) .replace(/</g, "<") - .replace(/>/g, ">") -} + .replace(/>/g, ">"); +}; // given a function that accepts a "node-style" errback as its last argument, return // a function that returns a promise instead -export const denodeify = util.promisify +export const denodeify = util.promisify; export const rdfaToJsonLd = async (html: string) => { - return denodeify(jsonldLib.fromRDF)(html, { format: "text/html" }) + return denodeify(jsonldLib.fromRDF)(html, { format: "text/html" }); // // use it // jsonldLib.fromRDF(html, {format: 'text/html'}, function(err, data) { -} +}; export const isProbablyAbsoluteUrl = (someUrl: string): boolean => { - const absoluteUrlPattern = new RegExp("^(?:[a-z]+:)?//", "i") - return absoluteUrlPattern.test(someUrl) -} + const absoluteUrlPattern = new RegExp("^(?:[a-z]+:)?//", "i"); + return absoluteUrlPattern.test(someUrl); +}; -export const ensureArray = <T>(itemOrItems: T | T[]): T[] => itemOrItems instanceof Array ? itemOrItems : [itemOrItems] +export const ensureArray = <T>(itemOrItems: T | T[]): T[] => + itemOrItems instanceof Array ? itemOrItems : [itemOrItems]; export const flatten = <T>(listOfLists: T[][]): T[] => - listOfLists.reduce((flattened, list: T[]) => flattened.concat(list), []) + listOfLists.reduce((flattened, list: T[]) => flattened.concat(list), []); // given an http request, return a number that is the maximum number of results this client wants in this response export const requestMaxMemberCount = (req: http.ServerRequest) => { const headerMatch = ensureArray(req.headers.prefer) .filter(Boolean) - .map((header) => header.match(/max-member-count="(\d+)"/)) - .filter(Boolean)[0] - if (headerMatch) { return parseInt(headerMatch[1], 10) } + .map(header => header.match(/max-member-count="(\d+)"/)) + .filter(Boolean)[0]; + if (headerMatch) { + return parseInt(headerMatch[1], 10); + } // check querystring - return parseInt(first(url.parse(req.url, true).query["max-member-count"]), 10) -} + return parseInt( + first(url.parse(req.url, true).query["max-member-count"]), + 10, + ); +}; -export const createHttpOrHttpsRequest = (urlOrObj: string|UrlObject) => { - const parsedUrl: UrlObject = (typeof urlOrObj === "string") ? url.parse(urlOrObj) : urlOrObj - let createRequest +export const createHttpOrHttpsRequest = (urlOrObj: string | UrlObject) => { + const parsedUrl: UrlObject = + typeof urlOrObj === "string" ? url.parse(urlOrObj) : urlOrObj; + let createRequest; switch (parsedUrl.protocol) { case "https:": - createRequest = https.request.bind(https) - break + createRequest = https.request.bind(https); + break; case "http:": - createRequest = http.request.bind(http) - break + createRequest = http.request.bind(http); + break; default: - const activityUrl = url.format(parsedUrl) + const activityUrl = url.format(parsedUrl); throw new Error( - "Can't fetch activity with unsupported protocol in URL (only http, https supported): " + activityUrl) + "Can't fetch activity with unsupported protocol in URL (only http, https supported): " + + activityUrl, + ); } - return createRequest(urlOrObj) -} + return createRequest(urlOrObj); +}; // given a Link object or url string, return an href string that can be used to refer to it -export const linkToHref = (hrefOrLinkObj: ASLink|string) => { - if (typeof hrefOrLinkObj === "string") { return hrefOrLinkObj } - if (typeof hrefOrLinkObj === "object") { return hrefOrLinkObj.href } - throw new Error("Unexpected link type: " + typeof hrefOrLinkObj) -} +export const linkToHref = (hrefOrLinkObj: ASLink | string) => { + if (typeof hrefOrLinkObj === "string") { + return hrefOrLinkObj; + } + if (typeof hrefOrLinkObj === "object") { + return hrefOrLinkObj.href; + } + throw new Error("Unexpected link type: " + typeof hrefOrLinkObj); +}; -jsonldLib.documentLoader = createCustomDocumentLoader() +jsonldLib.documentLoader = createCustomDocumentLoader(); -export const jsonld = jsonldLib.promises +export const jsonld = jsonldLib.promises; -type Errback = (err: Error, ...args: any[]) => void +type Errback = (err: Error, ...args: any[]) => void; function createCustomDocumentLoader() { // define a mapping of context URL => context doc - const CONTEXTS: {[key: string]: string} = { - "https://www.w3.org/ns/activitystreams": fs.readFileSync(path.join(__dirname, "/as2context.json"), "utf8"), - } + const CONTEXTS: { [key: string]: string } = { + "https://www.w3.org/ns/activitystreams": fs.readFileSync( + path.join(__dirname, "/as2context.json"), + "utf8", + ), + }; // grab the built-in node.js doc loader - const nodeDocumentLoader = jsonldLib.documentLoaders.node() + const nodeDocumentLoader = jsonldLib.documentLoaders.node(); // or grab the XHR one: jsonldLib.documentLoaders.xhr() // or grab the jquery one: jsonldLib.documentLoaders.jquery() @@ -219,22 +266,21 @@ function createCustomDocumentLoader() { // of using a callback) const customLoader = (someUrl: string, callback: Errback) => { if (someUrl in CONTEXTS) { - return callback( - null, { - contextUrl: null, // this is for a context via a link header - document: CONTEXTS[someUrl], // this is the actual document that was loaded - documentUrl: someUrl, // this is the actual context URL after redirects - }) + return callback(null, { + contextUrl: null, // this is for a context via a link header + document: CONTEXTS[someUrl], // this is the actual document that was loaded + documentUrl: someUrl, // this is the actual context URL after redirects + }); } // call the underlining documentLoader using the callback API. - nodeDocumentLoader(someUrl, callback) + nodeDocumentLoader(someUrl, callback); /* Note: By default, the node.js document loader uses a callback, but browser-based document loaders (xhr or jquery) return promises if they are supported (or polyfilled) in the browser. This behavior can be controlled with the 'usePromise' option when constructing the document loader. For example: jsonldLib.documentLoaders.xhr({usePromise: false}); */ - } - return customLoader + }; + return customLoader; } export function assertNever(x: never): never { @@ -242,32 +288,38 @@ export function assertNever(x: never): never { } // Return new value for a JSON-LD object's value, appending to any existing one -export function jsonldAppend(oldVal: any, valToAppend: any[]|any) { - valToAppend = Array.isArray(valToAppend) ? valToAppend : [valToAppend] - let newVal +export function jsonldAppend(oldVal: any, valToAppend: any[] | any) { + valToAppend = Array.isArray(valToAppend) ? valToAppend : [valToAppend]; + let newVal; switch (typeof oldVal) { case "object": if (Array.isArray(oldVal)) { - newVal = oldVal.concat(valToAppend) + newVal = oldVal.concat(valToAppend); } else { - newVal = [oldVal, ...valToAppend] + newVal = [oldVal, ...valToAppend]; } - break + break; case "undefined": - newVal = valToAppend - break + newVal = valToAppend; + break; default: - newVal = [oldVal, ...valToAppend] - break + newVal = [oldVal, ...valToAppend]; + break; } - return newVal + return newVal; } -export const makeErrorClass = (name: string, setUp?: ((...args: any[]) => void)) => class extends Error { - constructor(msg: string, ...args: any[]) { - super(msg) - this.message = msg - this.name = name - if (typeof setUp === "function") { setUp.apply(this, arguments) } - } -} +export const makeErrorClass = ( + name: string, + setUp?: (...args: any[]) => void, +) => + class extends Error { + constructor(msg: string, ...args: any[]) { + super(msg); + this.message = msg; + this.name = name; + if (typeof setUp === "function") { + setUp.apply(this, arguments); + } + } + }; diff --git a/tsconfig.json b/tsconfig.json index 7d5047d..9dc5ce6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,19 +8,9 @@ "module": "commonjs", "target": "es6", "pretty": true, - "lib": [ - "es7", - "es2017" - ], + "lib": ["es7", "es2017"], "sourceMap": true }, - "include": [ - "src/**/*.ts", - "test/**/*.ts", - "bin/**/*.ts", - ], - "exclude": [ - "node_modules", - "dist" - ] + "include": ["src/**/*.ts", "test/**/*.ts", "bin/**/*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/tslint.json b/tslint.json index 97cd0a0..cde4ffd 100644 --- a/tslint.json +++ b/tslint.json @@ -1,7 +1,8 @@ { "defaultSeverity": "error", "extends": [ - "tslint:recommended" + "tslint:recommended", + "tslint-config-prettier" ], "jsRules": {}, "rules": {