Skip to content
Snippets Groups Projects
Commit 3f2e9f76 authored by Bartłomiej Górnicki's avatar Bartłomiej Górnicki
Browse files

chore: add eslint and prettier to lint source code

parent 2f3e42a4
No related branches found
No related tags found
1 merge request!12Improvements and fixes
Showing
with 2478 additions and 543 deletions
{
"extends": "@engrave/eslint-config-engrave"
}
\ No newline at end of file
module.exports = require('@engrave/eslint-config-engrave/prettier.config');
\ No newline at end of file
trailingComma: all
tabWidth: 4
semi: true
printWidth: 120
singleQuote: false
This diff is collapsed.
......@@ -13,15 +13,15 @@
},
"scripts": {
"build:cleanbefore": "rm -rf dist",
"build:lint": "echo \"tslint -c tslint.json -p tsconfig.lint.json\"",
"build:node": "tsc",
"build:node": "tsc -p tsconfig.build.json",
"build:browser": "rm -rf dist/browser/ && NODE_ENV=production webpack --mode=production --config webpack.config.js",
"build": "npm run build:cleanbefore && npm run build:lint && npm run build:node && npm run build:browser",
"build": "npm run build:cleanbefore && npm run lint && npm run build:node && npm run build:browser",
"prepare": "NODE_ENV=production npm run build",
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix",
"test": "mocha 'src/**/*.test.ts'",
"verify:browser": "testcafe --selector-timeout 2000 --assertion-timeout 2000 chrome browser-test/browser-test.js",
"verify:puppeteer": "testcafe --selector-timeout 2000 --assertion-timeout 2000 puppeteer browser-test/browser-test.js",
"lint-fix": "tslint --fix -c tslint.json -p tsconfig.lint.json",
"semantic-release": "semantic-release"
},
"dependencies": {
......@@ -35,19 +35,23 @@
"devDependencies": {
"@commitlint/cli": "18.6.1",
"@commitlint/config-conventional": "18.6.2",
"@engrave/eslint-config-engrave": "1.0.0",
"@semantic-release/gitlab": "13.0.3",
"@types/chai": "4.3.11",
"@types/jsdom": "21.1.6",
"@types/lodash": "4.14.202",
"@types/mocha": "10.0.6",
"@types/node": "20.11.20",
"@types/remarkable": "2.0.8",
"@types/sanitize-html": "2.11.0",
"@types/uuid": "9.0.8",
"chai": "4.4.1",
"eslint": "8.56.0",
"husky": "9.0.11",
"jsdom": "24.0.0",
"lodash": "4.17.21",
"mocha": "10.3.0",
"prettier": "3.2.5",
"semantic-release": "23.0.2",
"testcafe": "3.5.0",
"testcafe-browser-provider-puppeteer": "1.5.2",
......
import { AbstractUniverseLog } from "universe-log";
import {AbstractUniverseLog} from 'universe-log';
export class Log extends AbstractUniverseLog {
public static log(): Log {
......@@ -8,10 +8,10 @@ export class Log extends AbstractUniverseLog {
private constructor() {
super({
levelEnvs: ["HIVE_CONTENT_RENDERER_LOG_LEVEL", "ENGRAVE_LOG_LEVEL"],
levelEnvs: ['HIVE_CONTENT_RENDERER_LOG_LEVEL', 'ENGRAVE_LOG_LEVEL'],
metadata: {
library: "@hiveio/content-renderer",
},
library: '@hiveio/content-renderer'
}
});
}
}
import { DefaultRenderer } from "./renderers/default/DefaultRenderer";
export { DefaultRenderer } from "./renderers/default/DefaultRenderer";
import {DefaultRenderer} from './renderers/default/DefaultRenderer';
export {DefaultRenderer} from './renderers/default/DefaultRenderer';
export const HiveContentRenderer = {
DefaultRenderer,
DefaultRenderer
};
export default HiveContentRenderer;
// tslint:disable max-line-length quotemark
import { expect } from "chai";
import { JSDOM } from "jsdom";
import "mocha";
import {expect} from 'chai';
import {JSDOM} from 'jsdom';
import 'mocha';
import {Log} from '../../Log';
import {DefaultRenderer, RendererOptions} from './DefaultRenderer';
import { Log } from "../../Log";
import { DefaultRenderer } from "./DefaultRenderer";
describe("DefaultRender", () => {
const defaultOptions: DefaultRenderer.Options = {
baseUrl: "https://hive.blog/",
describe('DefaultRender', () => {
const defaultOptions: RendererOptions = {
baseUrl: 'https://hive.blog/',
breaks: true,
skipSanitization: false,
allowInsecureScriptTags: false,
addTargetBlankToLinks: true,
cssClassForInternalLinks: "hive-test",
cssClassForExternalLinks: "hive-test external",
cssClassForInternalLinks: 'hive-test',
cssClassForExternalLinks: 'hive-test external',
addNofollowToLinks: true,
doNotShowImages: false,
ipfsPrefix: "",
ipfsPrefix: '',
assetsWidth: 640,
assetsHeight: 480,
imageProxyFn: (url: string) => url,
usertagUrlFn: (account: string) => `https://hive.blog/@${account}`,
hashtagUrlFn: (hashtag: string) => `/trending/${hashtag}`,
isLinkSafeFn: (url: string) => true, // !!url.match(/^(\/(?!\/)|https:\/\/hive.blog)/),
addExternalCssClassToMatchingLinksFn: (url: string) => !url.match(/^(\/(?!\/)|https:\/\/hive.blog)/),
isLinkSafeFn: (_url: string) => true, // !!url.match(/^(\/(?!\/)|https:\/\/hive.blog)/),
addExternalCssClassToMatchingLinksFn: (url: string) => !url.match(/^(\/(?!\/)|https:\/\/hive.blog)/)
};
const tests = [
{ name: "Renders H1 headers correctly", raw: `# Header H1`, expected: "<h1>Header H1</h1>" },
{ name: "Renders H4 headers correctly", raw: `#### Header H4`, expected: "<h4>Header H4</h4>" },
{name: 'Renders H1 headers correctly', raw: `# Header H1`, expected: '<h1>Header H1</h1>'},
{name: 'Renders H4 headers correctly', raw: `#### Header H4`, expected: '<h4>Header H4</h4>'},
{
name: "Renders headers and paragraphs correctly",
raw: "# Header H1\n\nSome paragraph\n\n## Header H2\n\nAnother paragraph",
expected: "<h1>Header H1</h1>\n<p>Some paragraph</p>\n<h2>Header H2</h2>\n<p>Another paragraph</p>",
name: 'Renders headers and paragraphs correctly',
raw: '# Header H1\n\nSome paragraph\n\n## Header H2\n\nAnother paragraph',
expected: '<h1>Header H1</h1>\n<p>Some paragraph</p>\n<h2>Header H2</h2>\n<p>Another paragraph</p>'
},
{
name: "Renders hive mentions correctly",
raw: "Content @noisy another content",
expected: '<p>Content <a href="https://hive.blog/@noisy" class="hive-test">@noisy</a> another content</p>',
name: 'Renders hive mentions correctly',
raw: 'Content @noisy another content',
expected: '<p>Content <a href="https://hive.blog/@noisy" class="hive-test">@noisy</a> another content</p>'
},
{
name: "Renders hive hashtags correctly",
raw: "Content #pl-nuda another content",
expected: '<p>Content <a href="/trending/pl-nuda" class="hive-test">#pl-nuda</a> another content</p>',
name: 'Renders hive hashtags correctly',
raw: 'Content #pl-nuda another content',
expected: '<p>Content <a href="/trending/pl-nuda" class="hive-test">#pl-nuda</a> another content</p>'
},
{
name: "Embeds correctly vimeo video via paste",
name: 'Embeds correctly vimeo video via paste',
raw: '<iframe src="https://player.vimeo.com/video/174544848?byline=0" width="640" height="360" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>',
expected:
'<div class="videoWrapper"><iframe frameborder="0" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" src="https://player.vimeo.com/video/174544848" width="640" height="480"></iframe></div>',
'<div class="videoWrapper"><iframe frameborder="0" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" src="https://player.vimeo.com/video/174544848" width="640" height="480"></iframe></div>'
},
{
name: "Embeds correctly youtube video via paste",
name: 'Embeds correctly youtube video via paste',
raw: '<iframe width="560" height="315" src="https://www.youtube.com/embed/0nFkmd-A7jA" frameborder="0" allowfullscreen></iframe>',
expected:
'<div class="videoWrapper"><iframe width="640" height="480" src="https://www.youtube.com/embed/0nFkmd-A7jA" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" frameborder="0"></iframe></div>',
'<div class="videoWrapper"><iframe width="640" height="480" src="https://www.youtube.com/embed/0nFkmd-A7jA" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" frameborder="0"></iframe></div>'
},
{
name: "Embeds correctly youtube video via youtube.com link",
raw: "https://www.youtube.com/embed/0nFkmd-A7jA",
name: 'Embeds correctly youtube video via youtube.com link',
raw: 'https://www.youtube.com/embed/0nFkmd-A7jA',
expected:
'<p><div class="videoWrapper"><iframe width="640" height="480" src="https://www.youtube.com/embed/0nFkmd-A7jA" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" frameborder="0"></iframe></div></p>',
'<p><div class="videoWrapper"><iframe width="640" height="480" src="https://www.youtube.com/embed/0nFkmd-A7jA" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" frameborder="0"></iframe></div></p>'
},
{
name: "Embeds correctly youtube video via youtu.be link",
raw: "https://www.youtu.be/0nFkmd-A7jA",
name: 'Embeds correctly youtube video via youtu.be link',
raw: 'https://www.youtu.be/0nFkmd-A7jA',
expected:
'<p><div class="videoWrapper"><iframe width="640" height="480" src="https://www.youtube.com/embed/0nFkmd-A7jA" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" frameborder="0"></iframe></div></p>',
'<p><div class="videoWrapper"><iframe width="640" height="480" src="https://www.youtube.com/embed/0nFkmd-A7jA" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" frameborder="0"></iframe></div></p>'
},
{
name: "Allows links embedded via <a> tags",
name: 'Allows links embedded via <a> tags',
raw: '<a href="https://hive.blog/utopian-io/@blockchainstudio/drugswars-revenue-and-transaction-analysis" class="hive-test">Drugwars - revenue and transaction analysis</a>',
expected:
'<p><a href="https://hive.blog/utopian-io/@blockchainstudio/drugswars-revenue-and-transaction-analysis" class="hive-test">Drugwars - revenue and transaction analysis</a></p>',
'<p><a href="https://hive.blog/utopian-io/@blockchainstudio/drugswars-revenue-and-transaction-analysis" class="hive-test">Drugwars - revenue and transaction analysis</a></p>'
},
{
name: "Allows links embedded via <a> tags inside of markdown headers",
name: 'Allows links embedded via <a> tags inside of markdown headers',
raw: "## <a href='https://hive.blog/utopian-io/@blockchainstudio/drugswars-revenue-and-transaction-analysis' class='hive-test'>Drugwars - revenue and transaction analysis</a>",
expected:
'<h2><a href="https://hive.blog/utopian-io/@blockchainstudio/drugswars-revenue-and-transaction-analysis" class="hive-test">Drugwars - revenue and transaction analysis</a></h2>',
'<h2><a href="https://hive.blog/utopian-io/@blockchainstudio/drugswars-revenue-and-transaction-analysis" class="hive-test">Drugwars - revenue and transaction analysis</a></h2>'
},
{
name: "Allow for anchor id tags",
name: 'Allow for anchor id tags',
raw: "<a id='anchor'></a>",
expected: '<p><a href="#" id="anchor" class="hive-test external"></a></p>',
expected: '<p><a href="#" id="anchor" class="hive-test external"></a></p>'
},
{
name: "Allows links embedded via <a> tags with additional class added when condition is matching",
name: 'Allows links embedded via <a> tags with additional class added when condition is matching',
raw: '<a href="https://www.google.com" class="hive-test">Google</a>',
expected: '<p><a href="https://www.google.com" class="hive-test external">Google</a></p>',
expected: '<p><a href="https://www.google.com" class="hive-test external">Google</a></p>'
},
{
name: "Should remove additional unsafe attributes from a tag",
name: 'Should remove additional unsafe attributes from a tag',
raw: "<a fake='test'></a>",
expected: '<p><a href="#" class="hive-test external"></a></p>',
},
expected: '<p><a href="#" class="hive-test external"></a></p>'
}
];
tests.forEach((test) =>
......@@ -108,31 +106,31 @@ describe("DefaultRender", () => {
const renderedNode = JSDOM.fragment(rendered);
const comparisonNode = JSDOM.fragment(test.expected);
Log.log().debug("rendered", rendered);
Log.log().debug("expected", test.expected);
Log.log().debug('rendered', rendered);
Log.log().debug('expected', test.expected);
expect(renderedNode.isEqualNode(comparisonNode)).to.be.equal(true);
}),
})
);
it("Allows insecure script tags when allowInsecureScriptTags = true", () => {
const renderer = new DefaultRenderer({ ...defaultOptions, allowInsecureScriptTags: true });
it('Allows insecure script tags when allowInsecureScriptTags = true', () => {
const renderer = new DefaultRenderer({...defaultOptions, allowInsecureScriptTags: true});
const insecureContent = '<script src="">';
renderer.render(insecureContent);
});
it("Does not allow insecure script tags when allowInsecureScriptTags = false", () => {
it('Does not allow insecure script tags when allowInsecureScriptTags = false', () => {
const renderer = new DefaultRenderer({
...defaultOptions,
skipSanitization: true,
allowInsecureScriptTags: false,
allowInsecureScriptTags: false
});
const insecureContent = '<script src="">';
expect(() => renderer.render(insecureContent)).to.throw(/insecure content/);
});
it("Rejects mixed image tag", () => {
const renderer = new DefaultRenderer({ ...defaultOptions });
it('Rejects mixed image tag', () => {
const renderer = new DefaultRenderer({...defaultOptions});
const markup = `<img src="![img.jpg](https://img.jpg)"/>`;
const rendered = renderer.render(markup);
......
import ow from "ow";
// @ts-ignore
import { Remarkable } from "remarkable";
import { SecurityChecker } from "../../security/SecurityChecker";
import { DefaultRendererLocalization } from "./DefaultRendererLocalization";
import { AssetEmbedder } from "./embedder/AssetEmbedder";
import { PreliminarySanitizer } from "./sanitization/PreliminarySanitizer";
import { TagTransformingSanitizer } from "./sanitization/TagTransformingSanitizer";
import ow from 'ow';
import {Remarkable} from 'remarkable';
import {SecurityChecker} from '../../security/SecurityChecker';
import {AssetEmbedder} from './embedder/AssetEmbedder';
import {Localization, LocalizationOptions} from './LocalizationOptions';
import {PreliminarySanitizer} from './sanitization/PreliminarySanitizer';
import {TagTransformingSanitizer} from './sanitization/TagTransformingSanitizer';
export class DefaultRenderer {
private options: DefaultRenderer.Options;
private options: RendererOptions;
private tagTransformingSanitizer: TagTransformingSanitizer;
private embedder: AssetEmbedder;
public constructor(
options: DefaultRenderer.Options,
localization: DefaultRendererLocalization = DefaultRendererLocalization.DEFAULT,
) {
DefaultRenderer.Options.validate(options);
public constructor(options: RendererOptions, localization: LocalizationOptions = Localization.DEFAULT) {
this.validate(options);
this.options = options;
DefaultRendererLocalization.validate(localization);
Localization.validate(localization);
this.tagTransformingSanitizer = new TagTransformingSanitizer(
{
......@@ -33,9 +27,9 @@ export class DefaultRenderer {
cssClassForExternalLinks: this.options.cssClassForExternalLinks,
noImage: this.options.doNotShowImages,
isLinkSafeFn: this.options.isLinkSafeFn,
addExternalCssClassToMatchingLinksFn: this.options.addExternalCssClassToMatchingLinksFn,
addExternalCssClassToMatchingLinksFn: this.options.addExternalCssClassToMatchingLinksFn
},
localization,
localization
);
this.embedder = new AssetEmbedder(
......@@ -47,14 +41,14 @@ export class DefaultRenderer {
imageProxyFn: this.options.imageProxyFn,
hashtagUrlFn: this.options.hashtagUrlFn,
usertagUrlFn: this.options.usertagUrlFn,
baseUrl: this.options.baseUrl,
baseUrl: this.options.baseUrl
},
localization,
localization
);
}
public render(input: string): string {
ow(input, "input", ow.string.nonEmpty);
ow(input, 'input', ow.string.nonEmpty);
return this.doRender(input);
}
......@@ -67,7 +61,7 @@ export class DefaultRenderer {
text = this.wrapRenderedTextWithHtmlIfNeeded(text);
text = this.embedder.markAssets(text);
text = this.sanitize(text);
SecurityChecker.checkSecurity(text, { allowScriptTag: this.options.allowInsecureScriptTags });
SecurityChecker.checkSecurity(text, {allowScriptTag: this.options.allowInsecureScriptTags});
text = this.embedder.insertAssets(text);
return text;
......@@ -78,15 +72,15 @@ export class DefaultRenderer {
html: true, // remarkable renders first then sanitize runs...
breaks: this.options.breaks,
typographer: false, // https://github.com/jonschlinkert/remarkable/issues/142#issuecomment-221546793
quotes: "“”‘’",
quotes: '“”‘’'
});
return renderer.render(text);
}
private wrapRenderedTextWithHtmlIfNeeded(renderedText: string): string {
// If content isn't wrapped with an html element at this point, add it.
if (renderedText.indexOf("<html>") !== 0) {
renderedText = "<html>" + renderedText + "</html>";
if (renderedText.indexOf('<html>') !== 0) {
renderedText = '<html>' + renderedText + '</html>';
}
return renderedText;
}
......@@ -112,51 +106,43 @@ export class DefaultRenderer {
return this.tagTransformingSanitizer.sanitize(text);
}
}
export namespace DefaultRenderer {
export interface Options {
baseUrl: string;
breaks: boolean;
skipSanitization: boolean;
allowInsecureScriptTags: boolean;
addNofollowToLinks: boolean;
addTargetBlankToLinks?: boolean;
cssClassForInternalLinks?: string;
cssClassForExternalLinks?: string;
doNotShowImages: boolean;
ipfsPrefix: string;
assetsWidth: number;
assetsHeight: number;
imageProxyFn: (url: string) => string;
hashtagUrlFn: (hashtag: string) => string;
usertagUrlFn: (account: string) => string;
isLinkSafeFn: (url: string) => boolean;
addExternalCssClassToMatchingLinksFn: (url: string) => boolean;
}
export namespace Options {
export function validate(o: Options) {
ow(o.baseUrl, "Options.baseUrl", ow.string.nonEmpty);
ow(o.breaks, "Options.breaks", ow.boolean);
ow(o.skipSanitization, "Options.skipSanitization", ow.boolean);
ow(o.addNofollowToLinks, "Options.addNofollowToLinks", ow.boolean);
ow(o.addTargetBlankToLinks, "Options.addTargetBlankToLinks", ow.optional.boolean);
ow(o.cssClassForInternalLinks, "Options.cssClassForInternalLinks", ow.optional.string);
ow(o.cssClassForExternalLinks, "Options.cssClassForExternalLinks", ow.optional.string);
ow(o.doNotShowImages, "Options.doNotShowImages", ow.boolean);
ow(o.ipfsPrefix, "Options.ipfsPrefix", ow.string);
ow(o.assetsWidth, "Options.assetsWidth", ow.number.integer.positive);
ow(o.assetsHeight, "Options.assetsHeight", ow.number.integer.positive);
ow(o.imageProxyFn, "Options.imageProxyFn", ow.function);
ow(o.hashtagUrlFn, "Options.hashtagUrlFn", ow.function);
ow(o.usertagUrlFn, "Options.usertagUrlFn", ow.function);
ow(o.isLinkSafeFn, "TagTransformingSanitizer.Options.isLinkSafeFn", ow.function);
ow(
o.addExternalCssClassToMatchingLinksFn,
"TagTransformingSanitizer.Options.addExternalCssClassToMatchingLinksFn",
ow.function,
);
}
private validate(o: RendererOptions) {
ow(o, 'RendererOptions', ow.object);
ow(o.baseUrl, 'RendererOptions.baseUrl', ow.string.nonEmpty);
ow(o.breaks, 'RendererOptions.breaks', ow.boolean);
ow(o.skipSanitization, 'RendererOptions.skipSanitization', ow.boolean);
ow(o.addNofollowToLinks, 'RendererOptions.addNofollowToLinks', ow.boolean);
ow(o.addTargetBlankToLinks, 'RendererOptions.addTargetBlankToLinks', ow.optional.boolean);
ow(o.cssClassForInternalLinks, 'RendererOptions.cssClassForInternalLinks', ow.optional.string);
ow(o.cssClassForExternalLinks, 'RendererOptions.cssClassForExternalLinks', ow.optional.string);
ow(o.doNotShowImages, 'RendererOptions.doNotShowImages', ow.boolean);
ow(o.ipfsPrefix, 'RendererOptions.ipfsPrefix', ow.string);
ow(o.assetsWidth, 'RendererOptions.assetsWidth', ow.number.integer.positive);
ow(o.assetsHeight, 'RendererOptions.assetsHeight', ow.number.integer.positive);
ow(o.imageProxyFn, 'RendererOptions.imageProxyFn', ow.function);
ow(o.hashtagUrlFn, 'RendererOptions.hashtagUrlFn', ow.function);
ow(o.usertagUrlFn, 'RendererOptions.usertagUrlFn', ow.function);
ow(o.isLinkSafeFn, 'RendererOptions.isLinkSafeFn', ow.function);
ow(o.addExternalCssClassToMatchingLinksFn, 'RendererOptions.addExternalCssClassToMatchingLinksFn', ow.function);
}
}
export interface RendererOptions {
baseUrl: string;
breaks: boolean;
skipSanitization: boolean;
allowInsecureScriptTags: boolean;
addNofollowToLinks: boolean;
addTargetBlankToLinks?: boolean;
cssClassForInternalLinks?: string;
cssClassForExternalLinks?: string;
doNotShowImages: boolean;
ipfsPrefix: string;
assetsWidth: number;
assetsHeight: number;
imageProxyFn: (url: string) => string;
hashtagUrlFn: (hashtag: string) => string;
usertagUrlFn: (account: string) => string;
isLinkSafeFn: (url: string) => boolean;
addExternalCssClassToMatchingLinksFn: (url: string) => boolean;
}
import ow from "ow";
export interface DefaultRendererLocalization {
phishingWarning: string; // "Link expanded to plain text; beware of a potential phishing attempt"
externalLink: string; // "This link will take you away from example.com"
noImage: string; // "Images not allowed"
accountNameWrongLength: string; // "Account name should be between 3 and 16 characters long."
accountNameBadActor: string; // "This account is on a bad actor list"
accountNameWrongSegment: string; // "This account name contains a bad segment"
}
export namespace DefaultRendererLocalization {
export function validate(o: DefaultRendererLocalization) {
ow(o, "DefaultRendererLocalization", ow.object);
ow(o.phishingWarning, "DefaultRendererLocalization.phishingWarningMessage", ow.string.nonEmpty);
ow(o.externalLink, "DefaultRendererLocalization.externalLink", ow.string.nonEmpty);
ow(o.noImage, "DefaultRendererLocalization.noImage", ow.string.nonEmpty);
ow(o.accountNameWrongLength, "DefaultRendererLocalization.accountNameWrongLength", ow.string.nonEmpty);
ow(o.accountNameBadActor, "DefaultRendererLocalization.accountNameBadActor", ow.string.nonEmpty);
ow(o.accountNameWrongSegment, "DefaultRendererLocalization.accountNameWrongSegment", ow.string.nonEmpty);
}
export const DEFAULT: DefaultRendererLocalization = {
phishingWarning: "Link expanded to plain text; beware of a potential phishing attempt",
externalLink: "This link will take you away from example.com",
noImage: "Images not allowed",
accountNameWrongLength: "Account name should be between 3 and 16 characters long",
accountNameBadActor: "This account is on a bad actor list",
accountNameWrongSegment: "This account name contains a bad segment",
};
}
import ow from 'ow';
export class Localization {
public static validate(o: LocalizationOptions) {
ow(o, 'LocalizationOptions', ow.object);
ow(o.phishingWarning, 'LocalizationOptions.phishingWarning', ow.string.nonEmpty);
ow(o.externalLink, 'LocalizationOptions.externalLink', ow.string.nonEmpty);
ow(o.noImage, 'LocalizationOptions.noImage', ow.string.nonEmpty);
ow(o.accountNameWrongLength, 'LocalizationOptions.accountNameWrongLength', ow.string.nonEmpty);
ow(o.accountNameBadActor, 'LocalizationOptions.accountNameBadActor', ow.string.nonEmpty);
ow(o.accountNameWrongSegment, 'LocalizationOptions.accountNameWrongSegment', ow.string.nonEmpty);
}
public static DEFAULT: LocalizationOptions = {
phishingWarning: 'Link expanded to plain text; beware of a potential phishing attempt',
externalLink: 'This link will take you away from example.com',
noImage: 'Images not allowed',
accountNameWrongLength: 'Account name should be between 3 and 16 characters long',
accountNameBadActor: 'This account is on a bad actor list',
accountNameWrongSegment: 'This account name contains a bad segment'
};
}
export interface LocalizationOptions {
phishingWarning: string; // "Link expanded to plain text; beware of a potential phishing attempt"
externalLink: string; // "This link will take you away from example.com"
noImage: string; // "Images not allowed"
accountNameWrongLength: string; // "Account name should be between 3 and 16 characters long."
accountNameBadActor: string; // "This account is on a bad actor list"
accountNameWrongSegment: string; // "This account name contains a bad segment"
}
......@@ -17,14 +17,14 @@ export class StaticConfig {
if (!m || m.length !== 2) {
return null;
}
return "https://player.vimeo.com/video/" + m[1];
},
return 'https://player.vimeo.com/video/' + m[1];
}
},
{
re: /^(https?:)?\/\/www.youtube.com\/embed\/.*/i,
fn: (src: string) => {
return src.replace(/\?.+$/, ""); // strip query string (yt: autoplay=1,controls=0,showinfo=0, etc)
},
return src.replace(/\?.+$/, ''); // strip query string (yt: autoplay=1,controls=0,showinfo=0, etc)
}
},
{
re: /^https:\/\/w.soundcloud.com\/player\/.*/i,
......@@ -38,22 +38,22 @@ export class StaticConfig {
return null;
}
return (
"https://w.soundcloud.com/player/?url=" +
'https://w.soundcloud.com/player/?url=' +
m[1] +
"&auto_play=false&hide_related=false&show_comments=true" +
"&show_user=true&show_reposts=false&visual=true"
'&auto_play=false&hide_related=false&show_comments=true' +
'&show_user=true&show_reposts=false&visual=true'
);
},
}
},
{
re: /^(https?:)?\/\/player.twitch.tv\/.*/i,
fn: (src: string) => {
// <iframe src="https://player.twitch.tv/?channel=ninja" frameborder="0" allowfullscreen="true" scrolling="no" height="378" width="620">
return src;
},
},
}
}
],
noImageText: "(Image not shown due to low ratings)",
noImageText: '(Image not shown due to low ratings)',
allowedTags: `
div, iframe, del,
a, p, b, i, q, br, ul, li, ol, img, h1, h2, h3, h4, h5, h6, hr,
......@@ -61,6 +61,6 @@ export class StaticConfig {
strike, sup, sub
`
.trim()
.split(/,\s*/),
.split(/,\s*/)
};
}
import { DefaultRendererLocalization } from "../DefaultRendererLocalization";
import { AssetEmbedderOptions } from "./AssetEmbedderOptions";
import { HtmlDOMParser } from "./HtmlDOMParser";
import { VideoEmbedders } from "./videoembedders/VideoEmbedders";
import ow from 'ow';
import {LocalizationOptions} from '../LocalizationOptions';
import {HtmlDOMParser} from './HtmlDOMParser';
import {VideoEmbedders} from './videoembedders/VideoEmbedders';
export class AssetEmbedder {
private options: AssetEmbedderOptions;
private localization: DefaultRendererLocalization;
private localization: LocalizationOptions;
public constructor(options: AssetEmbedderOptions, localization: DefaultRendererLocalization) {
AssetEmbedderOptions.validate(options);
public constructor(options: AssetEmbedderOptions, localization: LocalizationOptions) {
AssetEmbedder.validate(options);
this.options = options;
this.localization = localization;
}
......@@ -22,8 +21,31 @@ export class AssetEmbedder {
public insertAssets(input: string): string {
const size = {
width: this.options.width,
height: this.options.height,
height: this.options.height
};
return VideoEmbedders.insertMarkedEmbedsToRenderedOutput(input, size);
}
public static validate(o: AssetEmbedderOptions) {
ow(o, 'AssetEmbedderOptions', ow.object);
ow(o.ipfsPrefix, 'AssetEmbedderOptions.ipfsPrefix', ow.string);
ow(o.width, 'AssetEmbedderOptions.width', ow.number.integer.positive);
ow(o.height, 'AssetEmbedderOptions.height', ow.number.integer.positive);
ow(o.hideImages, 'AssetEmbedderOptions.hideImages', ow.boolean);
ow(o.baseUrl, 'AssetEmbedderOptions.baseUrl', ow.string.nonEmpty);
ow(o.imageProxyFn, 'AssetEmbedderOptions.imageProxyFn', ow.function);
ow(o.hashtagUrlFn, 'AssetEmbedderOptions.hashtagUrlFn', ow.function);
ow(o.usertagUrlFn, 'AssetEmbedderOptions.usertagUrlFn', ow.function);
}
}
export interface AssetEmbedderOptions {
ipfsPrefix: string;
width: number;
height: number;
hideImages: boolean;
baseUrl: string;
imageProxyFn: (url: string) => string;
hashtagUrlFn: (hashtag: string) => string;
usertagUrlFn: (account: string) => string;
}
import ow from "ow";
export interface AssetEmbedderOptions {
ipfsPrefix: string;
width: number;
height: number;
hideImages: boolean;
baseUrl: string;
imageProxyFn: (url: string) => string;
hashtagUrlFn: (hashtag: string) => string;
usertagUrlFn: (account: string) => string;
}
export namespace AssetEmbedderOptions {
export function validate(o: AssetEmbedderOptions) {
ow(o.ipfsPrefix, "AssetEmbedderOptions.ipfsPrefix", ow.string);
ow(o.width, "AssetEmbedderOptions.width", ow.number.integer.positive);
ow(o.height, "AssetEmbedderOptions.height", ow.number.integer.positive);
ow(o.hideImages, "AssetEmbedderOptions.hideImages", ow.boolean);
ow(o.baseUrl, "AssetEmbedderOptions.baseUrl", ow.string.nonEmpty);
ow(o.imageProxyFn, "AssetEmbedderOptions.imageProxyFn", ow.function);
ow(o.hashtagUrlFn, "AssetEmbedderOptions.hashtagUrlFn", ow.function);
ow(o.usertagUrlFn, "AssetEmbedderOptions.usertagUrlFn", ow.function);
}
}
/**
* Based on: https://github.com/openhive-network/condenser/blob/master/src/shared/HtmlReady.test.js
*/
import {expect} from 'chai';
import 'mocha';
import {AssetEmbedderOptions} from './AssetEmbedder';
import {HtmlDOMParser} from './HtmlDOMParser';
// tslint:disable max-line-length
import { expect } from "chai";
import "mocha";
/* global describe, it, before, beforeEach, after, afterEach */
import { AssetEmbedderOptions } from "./AssetEmbedderOptions";
import { HtmlDOMParser } from "./HtmlDOMParser";
describe("HtmlDOMParser", () => {
describe('HtmlDOMParser', () => {
const htmlParserOptions: AssetEmbedderOptions = {
ipfsPrefix: "",
ipfsPrefix: '',
imageProxyFn: (url: string) => url,
usertagUrlFn: (account: string) => `/@${account}`,
hashtagUrlFn: (hashtag: string) => `/trending/${hashtag}`,
baseUrl: "https://hive.blog/",
baseUrl: 'https://hive.blog/',
width: 640,
height: 480,
hideImages: false,
hideImages: false
};
it("should allow links where the text portion and href contains hive.blog", () => {
it('should allow links where the text portion and href contains hive.blog', () => {
const dirty =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup" xmlns="http://www.w3.org/1999/xhtml">https://hive.blog/signup</a></xml>';
const parser = new HtmlDOMParser(htmlParserOptions);
......@@ -30,9 +26,8 @@ describe("HtmlDOMParser", () => {
expect(res).to.equal(dirty);
});
it("should allow in-page links ", () => {
const dirty =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="#some-link" xmlns="http://www.w3.org/1999/xhtml">a link location</a></xml>';
it('should allow in-page links ', () => {
const dirty = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="#some-link" xmlns="http://www.w3.org/1999/xhtml">a link location</a></xml>';
const parser = new HtmlDOMParser(htmlParserOptions);
const res = parser.parse(dirty).getParsedDocumentAsString();
expect(res).to.equal(dirty);
......@@ -43,7 +38,7 @@ describe("HtmlDOMParser", () => {
expect(externalDomainResult).to.equal(externalDomainDirty);
});
it("should not allow links where the text portion contains hive.blog but the link does not", () => {
it('should not allow links where the text portion contains hive.blog but the link does not', () => {
const dirty =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://steamit.com/signup" xmlns="http://www.w3.org/1999/xhtml">https://hive.blog/signup</a></xml>';
const cleansed =
......@@ -65,8 +60,7 @@ describe("HtmlDOMParser", () => {
const reswithuser = new HtmlDOMParser(htmlParserOptions).parse(withuser).getParsedDocumentAsString();
expect(reswithuser).to.equal(cleansedwithuser);
const noendingslash =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://steamit.com" xmlns="http://www.w3.org/1999/xhtml">https://hive.blog</a></xml>';
const noendingslash = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://steamit.com" xmlns="http://www.w3.org/1999/xhtml">https://hive.blog</a></xml>';
const cleansednoendingslash =
'<xml xmlns="http://www.w3.org/1999/xhtml"><div title="Link expanded to plain text; beware of a potential phishing attempt" class="phishy">https://hive.blog / https://steamit.com</div></xml>';
const resnoendingslash = new HtmlDOMParser(htmlParserOptions).parse(noendingslash).getParsedDocumentAsString();
......@@ -96,7 +90,7 @@ describe("HtmlDOMParser", () => {
expect(resnoprotocol).to.equal(cleansednoprotocol);
});
it("should allow more than one link per post", () => {
it('should allow more than one link per post', () => {
const somanylinks = '<xml xmlns="http://www.w3.org/1999/xhtml">https://foo.com and https://blah.com</xml>';
const htmlified =
'<xml xmlns="http://www.w3.org/1999/xhtml"><span><a href="https://foo.com">https://foo.com</a> and <a href="https://blah.com">https://blah.com</a></span></xml>';
......@@ -104,85 +98,68 @@ describe("HtmlDOMParser", () => {
expect(res).to.equal(htmlified);
});
it("should link usernames", () => {
it('should link usernames', () => {
const textwithmentions = '<xml xmlns="http://www.w3.org/1999/xhtml">@username (@a1b2, whatever</xml>';
const htmlified =
'<xml xmlns="http://www.w3.org/1999/xhtml"><span><a href="/@username">@username</a> (<a href="/@a1b2">@a1b2</a>, whatever</span></xml>';
const htmlified = '<xml xmlns="http://www.w3.org/1999/xhtml"><span><a href="/@username">@username</a> (<a href="/@a1b2">@a1b2</a>, whatever</span></xml>';
const res = new HtmlDOMParser(htmlParserOptions).parse(textwithmentions).getParsedDocumentAsString();
expect(res).to.equal(htmlified);
});
it("should detect only valid mentions", () => {
const textwithmentions = "@abc @xx (@aaa1) @_x @eee, @fff! https://x.com/@zzz/test";
const res = new HtmlDOMParser(htmlParserOptions)
.setMutateEnabled(false)
.parse(textwithmentions)
.getState();
const usertags = Array.from(res.usertags).join(",");
expect(usertags).to.equal("abc,aaa1,eee,fff");
it('should detect only valid mentions', () => {
const textwithmentions = '@abc @xx (@aaa1) @_x @eee, @fff! https://x.com/@zzz/test';
const res = new HtmlDOMParser(htmlParserOptions).setMutateEnabled(false).parse(textwithmentions).getState();
const usertags = Array.from(res.usertags).join(',');
expect(usertags).to.equal('abc,aaa1,eee,fff');
});
it("should not link usernames at the front of linked text", () => {
const nameinsidelinkfirst =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">@hihi</a></xml>';
const htmlified =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">@hihi</a></xml>';
it('should not link usernames at the front of linked text', () => {
const nameinsidelinkfirst = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">@hihi</a></xml>';
const htmlified = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">@hihi</a></xml>';
const res = new HtmlDOMParser(htmlParserOptions).parse(nameinsidelinkfirst).getParsedDocumentAsString();
expect(res).to.equal(htmlified);
});
it("should not link usernames in the middle of linked text", () => {
const nameinsidelinkmiddle =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">hi @hihi</a></xml>';
const htmlified =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">hi @hihi</a></xml>';
it('should not link usernames in the middle of linked text', () => {
const nameinsidelinkmiddle = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">hi @hihi</a></xml>';
const htmlified = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://hive.blog/signup">hi @hihi</a></xml>';
const res = new HtmlDOMParser(htmlParserOptions).parse(nameinsidelinkmiddle).getParsedDocumentAsString();
expect(res).to.equal(htmlified);
});
it("should make relative links absolute with https by default", () => {
const noRelativeHttpHttpsOrHive =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="land.com"> zippy </a> </xml>';
const cleansedRelativeHttpHttpsOrHive =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://land.com"> zippy </a> </xml>';
const resNoRelativeHttpHttpsOrHive = new HtmlDOMParser(htmlParserOptions)
.parse(noRelativeHttpHttpsOrHive)
.getParsedDocumentAsString();
it('should make relative links absolute with https by default', () => {
const noRelativeHttpHttpsOrHive = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="land.com"> zippy </a> </xml>';
const cleansedRelativeHttpHttpsOrHive = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://land.com"> zippy </a> </xml>';
const resNoRelativeHttpHttpsOrHive = new HtmlDOMParser(htmlParserOptions).parse(noRelativeHttpHttpsOrHive).getParsedDocumentAsString();
expect(resNoRelativeHttpHttpsOrHive).to.equal(cleansedRelativeHttpHttpsOrHive);
});
it("should allow the hive uri scheme for vessel links", () => {
const noRelativeHttpHttpsOrHive =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="hive://veins.com"> arteries </a> </xml>';
const cleansedRelativeHttpHttpsOrHive =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="hive://veins.com"> arteries </a> </xml>';
const resNoRelativeHttpHttpsOrHive = new HtmlDOMParser(htmlParserOptions)
.parse(noRelativeHttpHttpsOrHive)
.getParsedDocumentAsString();
it('should allow the hive uri scheme for vessel links', () => {
const noRelativeHttpHttpsOrHive = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="hive://veins.com"> arteries </a> </xml>';
const cleansedRelativeHttpHttpsOrHive = '<xml xmlns="http://www.w3.org/1999/xhtml"><a href="hive://veins.com"> arteries </a> </xml>';
const resNoRelativeHttpHttpsOrHive = new HtmlDOMParser(htmlParserOptions).parse(noRelativeHttpHttpsOrHive).getParsedDocumentAsString();
expect(resNoRelativeHttpHttpsOrHive).to.equal(cleansedRelativeHttpHttpsOrHive);
});
it("should not mistake usernames in valid comment urls as mentions", () => {
const url =
"https://hive.blog/spam/@test-safari/34gfex-december-spam#@test-safari/re-test-safari-34gfex-december-spam-20180110t234627522z";
it('should not mistake usernames in valid comment urls as mentions', () => {
const url = 'https://hive.blog/spam/@test-safari/34gfex-december-spam#@test-safari/re-test-safari-34gfex-december-spam-20180110t234627522z';
const prefix = '<xml xmlns="http://www.w3.org/1999/xhtml">';
const suffix = "</xml>";
const suffix = '</xml>';
const input = prefix + url + suffix;
const expected = prefix + '<span><a href="' + url + '">' + url + "</a></span>" + suffix;
const expected = prefix + '<span><a href="' + url + '">' + url + '</a></span>' + suffix;
const result = new HtmlDOMParser(htmlParserOptions).parse(input).getParsedDocumentAsString();
expect(result).to.equal(expected);
});
it("should not modify text when mention contains invalid username", () => {
const body = "valid mention match but invalid username..@usernamewaytoolong";
it('should not modify text when mention contains invalid username', () => {
const body = 'valid mention match but invalid username..@usernamewaytoolong';
const prefix = '<xml xmlns="http://www.w3.org/1999/xhtml">';
const suffix = "</xml>";
const suffix = '</xml>';
const input = prefix + body + suffix;
const result = new HtmlDOMParser(htmlParserOptions).parse(input).getParsedDocumentAsString();
expect(result).to.equal(input);
});
it("should detect urls that are phishy", () => {
it('should detect urls that are phishy', () => {
const dirty =
'<xml xmlns="http://www.w3.org/1999/xhtml"><a href="https://steewit.com/signup" xmlns="http://www.w3.org/1999/xhtml">https://hive.blog/signup</a></xml>';
const cleansed =
......@@ -191,18 +168,16 @@ describe("HtmlDOMParser", () => {
expect(res).to.equal(cleansed);
});
it("should not omit text on same line as youtube link", () => {
const testString = "<html><p>before text https://www.youtube.com/watch?v=NrS9vvNgx7I after text</p></html>";
const htmlified =
'<html xmlns="http://www.w3.org/1999/xhtml"><p>before text ~~~ embed:NrS9vvNgx7I youtube ~~~ after text</p></html>';
it('should not omit text on same line as youtube link', () => {
const testString = '<html><p>before text https://www.youtube.com/watch?v=NrS9vvNgx7I after text</p></html>';
const htmlified = '<html xmlns="http://www.w3.org/1999/xhtml"><p>before text ~~~ embed:NrS9vvNgx7I youtube ~~~ after text</p></html>';
const res = new HtmlDOMParser(htmlParserOptions).parse(testString).getParsedDocumentAsString();
expect(res).to.equal(htmlified);
});
it("should not omit text on same line as vimeo link", () => {
const testString = "<html><p>before text https://vimeo.com/193628816/ after text</p></html>";
const htmlified =
'<html xmlns="http://www.w3.org/1999/xhtml"><p>before text ~~~ embed:193628816 vimeo ~~~ after text</p></html>';
it('should not omit text on same line as vimeo link', () => {
const testString = '<html><p>before text https://vimeo.com/193628816/ after text</p></html>';
const htmlified = '<html xmlns="http://www.w3.org/1999/xhtml"><p>before text ~~~ embed:193628816 vimeo ~~~ after text</p></html>';
const res = new HtmlDOMParser(htmlParserOptions).parse(testString).getParsedDocumentAsString();
expect(res).to.equal(htmlified);
});
......
......@@ -2,22 +2,20 @@
* Based on: https://github.com/openhive-network/condenser/blob/master/src/shared/HtmlReady.js
*/
// tslint:disable max-classes-per-file
import * as xmldom from "@xmldom/xmldom";
import ChainedError from "typescript-chained-error";
import { Log } from "../../../Log";
import { LinkSanitizer } from "../../../security/LinkSanitizer";
import { DefaultRendererLocalization } from "../DefaultRendererLocalization";
import { AssetEmbedderOptions } from "./AssetEmbedderOptions";
import { AccountNameValidator } from "./utils/AccountNameValidator";
import linksRe, { any as linksAny } from "./utils/Links";
import { VideoEmbedders } from "./videoembedders/VideoEmbedders";
import { YoutubeEmbedder } from "./videoembedders/YoutubeEmbedder";
import * as xmldom from '@xmldom/xmldom';
import ChainedError from 'typescript-chained-error';
import {Log} from '../../../Log';
import {LinkSanitizer} from '../../../security/LinkSanitizer';
import {Localization, LocalizationOptions} from '../LocalizationOptions';
import {AssetEmbedder, AssetEmbedderOptions} from './AssetEmbedder';
import {AccountNameValidator} from './utils/AccountNameValidator';
import linksRe, {any as linksAny} from './utils/Links';
import {VideoEmbedders} from './videoembedders/VideoEmbedders';
import {YoutubeEmbedder} from './videoembedders/YoutubeEmbedder';
export class HtmlDOMParser {
private options: AssetEmbedderOptions;
private localization: DefaultRendererLocalization;
private localization: LocalizationOptions;
private linkSanitizer: LinkSanitizer;
private domParser = new xmldom.DOMParser({
......@@ -27,23 +25,21 @@ export class HtmlDOMParser {
},
error: () => {
/* */
},
},
}
}
});
private xmlSerializer = new xmldom.XMLSerializer();
private state: HtmlDOMParser.State;
private state: State;
private mutate = true;
private parsedDocument: Document | undefined = undefined;
public constructor(
options: AssetEmbedderOptions,
localization: DefaultRendererLocalization = DefaultRendererLocalization.DEFAULT,
) {
AssetEmbedderOptions.validate(options);
public constructor(options: AssetEmbedderOptions, localization: LocalizationOptions = Localization.DEFAULT) {
AssetEmbedder.validate(options);
Localization.validate(localization);
this.options = options;
this.localization = localization;
this.linkSanitizer = new LinkSanitizer({
baseUrl: this.options.baseUrl,
baseUrl: this.options.baseUrl
});
this.state = {
......@@ -51,7 +47,7 @@ export class HtmlDOMParser {
usertags: new Set(),
htmltags: new Set(),
images: new Set(),
links: new Set(),
links: new Set()
};
}
......@@ -62,24 +58,24 @@ export class HtmlDOMParser {
public parse(html: string): HtmlDOMParser {
try {
const doc: Document = this.domParser.parseFromString(html, "text/html");
const doc: Document = this.domParser.parseFromString(html, 'text/html');
this.traverseDOMNode(doc);
if (this.mutate) this.postprocessDOM(doc);
this.parsedDocument = doc;
} catch (error) {
throw new HtmlDOMParser.HtmlDOMParserError("Parsing error", error as Error);
throw new HtmlDOMParserError('Parsing error', error as Error);
}
return this;
}
public getState(): HtmlDOMParser.State {
if (!this.parsedDocument) throw new HtmlDOMParser.HtmlDOMParserError("Html has not been parsed yet");
public getState(): State {
if (!this.parsedDocument) throw new HtmlDOMParserError('Html has not been parsed yet');
return this.state;
}
public getParsedDocument(): Document {
if (!this.parsedDocument) throw new HtmlDOMParser.HtmlDOMParserError("Html has not been parsed yet");
if (!this.parsedDocument) throw new HtmlDOMParserError('Html has not been parsed yet');
return this.parsedDocument;
}
......@@ -92,19 +88,19 @@ export class HtmlDOMParser {
return;
}
Array.from(node.childNodes).forEach(child => {
Array.from(node.childNodes).forEach((child) => {
const tag = (child as any).tagName ? (child as any).tagName.toLowerCase() : null;
if (tag) {
this.state.htmltags.add(tag);
}
if (tag === "img") {
if (tag === 'img') {
this.processImgTag(child as HTMLObjectElement);
} else if (tag === "iframe") {
} else if (tag === 'iframe') {
this.processIframeTag(child as HTMLObjectElement);
} else if (tag === "a") {
} else if (tag === 'a') {
this.processLinkTag(child as HTMLObjectElement);
} else if (child.nodeName === "#text") {
} else if (child.nodeName === '#text') {
this.processTextNode(child as HTMLObjectElement);
}
......@@ -113,21 +109,21 @@ export class HtmlDOMParser {
}
private processLinkTag(child: HTMLObjectElement) {
const url = child.getAttribute("href");
const url = child.getAttribute('href');
if (url) {
this.state.links.add(url);
if (this.mutate) {
// Unlink potential phishing attempts
const urlTitle = child.textContent + "";
const urlTitle = child.textContent + '';
const sanitizedLink = this.linkSanitizer.sanitizeLink(url, urlTitle);
if (sanitizedLink === false) {
const phishyDiv = (child as any).ownerDocument.createElement("div");
const phishyDiv = (child as any).ownerDocument.createElement('div');
phishyDiv.textContent = `${child.textContent} / ${url}`;
phishyDiv.setAttribute("title", this.localization.phishingWarning);
phishyDiv.setAttribute("class", "phishy");
phishyDiv.setAttribute('title', this.localization.phishingWarning);
phishyDiv.setAttribute('class', 'phishy');
(child as any).parentNode.replaceChild(phishyDiv, child);
} else {
child.setAttribute("href", sanitizedLink);
child.setAttribute('href', sanitizedLink);
}
}
}
......@@ -135,46 +131,41 @@ export class HtmlDOMParser {
// wrap iframes in div.videoWrapper to control size/aspect ratio
private processIframeTag(child: HTMLObjectElement) {
const url = child.getAttribute("src");
const url = child.getAttribute('src');
if (url) this.reportIframeLink(url);
if (!this.mutate) {
return;
}
const tag = (child as any).parentNode.tagName
? (child as any).parentNode.tagName.toLowerCase()
: (child as any).parentNode.tagName;
if (tag === "div" && (child as any).parentNode.getAttribute("class") === "videoWrapper") {
const tag = (child as any).parentNode.tagName ? (child as any).parentNode.tagName.toLowerCase() : (child as any).parentNode.tagName;
if (tag === 'div' && (child as any).parentNode.getAttribute('class') === 'videoWrapper') {
return;
}
const html = this.xmlSerializer.serializeToString(child);
(child as any).parentNode.replaceChild(
this.domParser.parseFromString(`<div class="videoWrapper">${html}</div>`),
child,
);
(child as any).parentNode.replaceChild(this.domParser.parseFromString(`<div class="videoWrapper">${html}</div>`), child);
}
private reportIframeLink(url: string) {
const yt = YoutubeEmbedder.getYoutubeMetadataFromLink(url);
if (yt) {
this.state.links.add(yt.url);
this.state.images.add("https://img.youtube.com/vi/" + yt.id + "/0.jpg");
this.state.images.add('https://img.youtube.com/vi/' + yt.id + '/0.jpg');
}
}
private processImgTag(child: HTMLObjectElement) {
const url = child.getAttribute("src");
const url = child.getAttribute('src');
if (url) {
this.state.images.add(url);
if (this.mutate) {
let url2 = this.normalizeUrl(url);
if (/^\/\//.test(url2)) {
// Change relative protocol imgs to https
url2 = "https:" + url2;
url2 = 'https:' + url2;
}
if (url2 !== url) {
child.setAttribute("src", url2);
child.setAttribute('src', url2);
}
}
}
......@@ -182,13 +173,11 @@ export class HtmlDOMParser {
private processTextNode(child: HTMLObjectElement) {
try {
const tag = (child.parentNode as any).tagName
? (child.parentNode as any).tagName.toLowerCase()
: (child.parentNode as any).tagName;
if (tag === "code") {
const tag = (child.parentNode as any).tagName ? (child.parentNode as any).tagName.toLowerCase() : (child.parentNode as any).tagName;
if (tag === 'code') {
return;
}
if (tag === "a") {
if (tag === 'a') {
return;
}
......@@ -197,8 +186,8 @@ export class HtmlDOMParser {
}
const embedResp = VideoEmbedders.processTextNodeAndInsertEmbeds(child);
embedResp.images.forEach(img => this.state.images.add(img));
embedResp.links.forEach(link => this.state.links.add(link));
embedResp.images.forEach((img) => this.state.images.add(img));
embedResp.links.forEach((link) => this.state.links.add(link));
const data = this.xmlSerializer.serializeToString(child);
const content = this.linkify(data);
......@@ -214,7 +203,7 @@ export class HtmlDOMParser {
private linkify(content: string) {
// plaintext links
content = content.replace(linksAny("gi"), ln => {
content = content.replace(linksAny('gi'), (ln) => {
if (linksRe.image.test(ln)) {
this.state.images.add(ln);
return `<img src="${this.normalizeUrl(ln)}" />`;
......@@ -237,11 +226,11 @@ export class HtmlDOMParser {
});
// hashtag
content = content.replace(/(^|\s)(#[-a-z\d]+)/gi, tag => {
content = content.replace(/(^|\s)(#[-a-z\d]+)/gi, (tag) => {
if (/#[\d]+$/.test(tag)) {
return tag;
} // Don't allow numbers to be tags
const space = /^\s/.test(tag) ? tag[0] : "";
const space = /^\s/.test(tag) ? tag[0] : '';
const tag2 = tag.trim().substring(1);
const tagLower = tag2.toLowerCase();
this.state.hashtags.add(tagLower);
......@@ -254,27 +243,24 @@ export class HtmlDOMParser {
// usertag (mention)
// Cribbed from https://github.com/twitter/twitter-text/blob/v1.14.7/js/twitter-text.js#L90
content = content.replace(
/(^|[^a-zA-Z0-9_!#$%&*@@\/]|(^|[^a-zA-Z0-9_+~.-\/#]))[@@]([a-z][-\.a-z\d]+[a-z\d])/gi,
(match, preceeding1, preceeding2, user) => {
const userLower = user.toLowerCase();
const valid = AccountNameValidator.validateAccountName(userLower, this.localization) == null;
if (valid && this.state.usertags) {
this.state.usertags.add(userLower);
}
content = content.replace(/(^|[^a-zA-Z0-9_!#$%&*@@/]|(^|[^a-zA-Z0-9_+~.-/#]))[@@]([a-z][-.a-z\d]+[a-z\d])/gi, (_match, preceeding1, preceeding2, user) => {
const userLower = user.toLowerCase();
const valid = AccountNameValidator.validateAccountName(userLower, this.localization) == null;
// include the preceeding matches if they exist
const preceedings = (preceeding1 || "") + (preceeding2 || "");
if (valid && this.state.usertags) {
this.state.usertags.add(userLower);
}
if (!this.mutate) {
return `${preceedings}${user}`;
}
// include the preceeding matches if they exist
const preceedings = (preceeding1 || '') + (preceeding2 || '');
const userTagUrl = this.options.usertagUrlFn(userLower);
return valid ? `${preceedings}<a href="${userTagUrl}">@${user}</a>` : `${preceedings}@${user}`;
},
);
if (!this.mutate) {
return `${preceedings}${user}`;
}
const userTagUrl = this.options.usertagUrlFn(userLower);
return valid ? `${preceedings}<a href="${userTagUrl}">@${user}</a>` : `${preceedings}@${user}`;
});
return content;
}
......@@ -285,10 +271,10 @@ export class HtmlDOMParser {
private hideImagesIfNeeded(doc: Document) {
if (this.mutate && this.options.hideImages) {
for (const image of Array.from(doc.getElementsByTagName("img"))) {
const pre = doc.createElement("pre");
pre.setAttribute("class", "image-url-only");
pre.appendChild(doc.createTextNode(image.getAttribute("src") || ""));
for (const image of Array.from(doc.getElementsByTagName('img'))) {
const pre = doc.createElement('pre');
pre.setAttribute('class', 'image-url-only');
pre.appendChild(doc.createTextNode(image.getAttribute('src') || ''));
if (image.parentNode) {
image.parentNode.replaceChild(pre, image);
}
......@@ -307,10 +293,10 @@ export class HtmlDOMParser {
if (!doc) {
return;
}
Array.from(doc.getElementsByTagName("img")).forEach(node => {
const url: string = node.getAttribute("src") || "";
Array.from(doc.getElementsByTagName('img')).forEach((node) => {
const url: string = node.getAttribute('src') || '';
if (!linksRe.local.test(url)) {
node.setAttribute("src", this.options.imageProxyFn(url));
node.setAttribute('src', this.options.imageProxyFn(url));
}
});
}
......@@ -319,99 +305,25 @@ export class HtmlDOMParser {
if (this.options.ipfsPrefix) {
// Convert //ipfs/xxx or /ipfs/xxx into https://images.hive.blog/ipfs/xxxxx
if (/^\/?\/ipfs\//.test(url)) {
const slash = url.charAt(1) === "/" ? 1 : 0;
url = url.substring(slash + "/ipfs/".length); // start with only 1 /
return this.options.ipfsPrefix + "/" + url;
const slash = url.charAt(1) === '/' ? 1 : 0;
url = url.substring(slash + '/ipfs/'.length); // start with only 1 /
return this.options.ipfsPrefix + '/' + url;
}
}
return url;
}
}
export namespace HtmlDOMParser {
export interface State {
hashtags: Set<string>;
usertags: Set<string>;
htmltags: Set<string>;
images: Set<string>;
links: Set<string>;
}
export interface State {
hashtags: Set<string>;
usertags: Set<string>;
htmltags: Set<string>;
images: Set<string>;
links: Set<string>;
}
export class HtmlDOMParserError extends ChainedError {
public constructor(message?: string, cause?: Error) {
super(message, cause);
}
export class HtmlDOMParserError extends ChainedError {
public constructor(message?: string, cause?: Error) {
super(message, cause);
}
}
/*
*/
/****************
* Legacy docs of HtmlReady:
*/
/**
* Functions performed by HTMLReady
*
* State reporting
* - hashtags: collect all #tags in content
* - usertags: collect all @mentions in content
* - htmltags: collect all html <tags> used (for validation)
* - images: collect all image URLs in content
* - links: collect all href URLs in content
*
* Mutations
* - link()
* - ensure all <a> href's begin with a protocol. prepend https:// otherwise.
* - iframe()
* - wrap all <iframe>s in <div class="videoWrapper"> for responsive sizing
* - img()
* - convert any <img> src IPFS prefixes to standard URL
* - change relative protocol to https://
* - linkifyNode()
* - scans text content to be turned into rich content
* - embedYouTubeNode()
* - identify plain youtube URLs and prep them for "rich embed"
* - linkify()
* - scan text for:
* - #tags, convert to <a> links
* - @mentions, convert to <a> links
* - naked URLs
* - if img URL, normalize URL and convert to <img> tag
* - otherwise, normalize URL and convert to <a> link
* - proxifyImages()
* - prepend proxy URL to any non-local <img> src's
*
* We could implement 2 levels of HTML mutation for maximum reuse:
* 1. Normalization of HTML - non-proprietary, pre-rendering cleanup/normalization
* - (state reporting done at this level)
* - normalize URL protocols
* - convert naked URLs to images/links
* - convert embeddable URLs to <iframe>s
* - basic sanitization?
* 2. Steemit.com Rendering - add in proprietary Steemit.com functions/links
* - convert <iframe>s to custom objects
* - linkify #tags and @mentions
* - proxify images
*
* TODO:
* - change ipfsPrefix(url) to normalizeUrl(url)
* - rewrite IPFS prefixes to valid URLs
* - schema normalization
* - gracefully handle protocols like ftp, mailto
*/
/** Split the HTML on top-level elements. This allows react to compare separately, preventing excessive re-rendering.
* Used in MarkdownViewer.jsx
*/
// export function sectionHtml (html) {
// const doc = this.domParser.parseFromString(html, 'text/html')
// const sections = Array(...doc.childNodes).map(child => this.xmlSerializer.serializeToString(child))
// return sections
// }
/* Embed videos, link mentions and hashtags, etc...
If hideImages and mutate is set to true all images will be replaced
by <pre> elements containing just the image url.
*/
import { expect } from "chai";
import {expect} from 'chai';
import {Localization} from '../../LocalizationOptions';
import {AccountNameValidator} from './AccountNameValidator';
import { DefaultRendererLocalization } from "../../DefaultRendererLocalization";
import { AccountNameValidator } from "./AccountNameValidator";
describe("AccountNameValidator", () => {
["", "a", "aa", "nametoolongtohandle"].forEach((input) => {
describe('AccountNameValidator', () => {
['', 'a', 'aa', 'nametoolongtohandle'].forEach((input) => {
it(`should return accountNameWrongLength for invalid account name (${input})`, () => {
const actual = AccountNameValidator.validateAccountName(input, DefaultRendererLocalization.DEFAULT);
expect(actual).to.be.equal(DefaultRendererLocalization.DEFAULT.accountNameWrongLength);
const actual = AccountNameValidator.validateAccountName(input, Localization.DEFAULT);
expect(actual).to.be.equal(Localization.DEFAULT.accountNameWrongLength);
});
});
it("should return accountNameBadActor for bad actor account name", () => {
const actual = AccountNameValidator.validateAccountName("aalpha", DefaultRendererLocalization.DEFAULT);
expect(actual).to.be.equal(DefaultRendererLocalization.DEFAULT.accountNameBadActor);
it('should return accountNameBadActor for bad actor account name', () => {
const actual = AccountNameValidator.validateAccountName('aalpha', Localization.DEFAULT);
expect(actual).to.be.equal(Localization.DEFAULT.accountNameBadActor);
});
["something.", ".something", "a..a", "something.a", "a.something", "a.a.a", "something.ab", "123", "3speak", "something.123", "-something", "something-"].forEach((input) => {
it(`should return accountNameWrongSegment for invalid account name (${input})`, () => {
const actual = AccountNameValidator.validateAccountName(input, DefaultRendererLocalization.DEFAULT);
expect(actual).to.be.equal(DefaultRendererLocalization.DEFAULT.accountNameWrongSegment);
});
});
['something.', '.something', 'a..a', 'something.a', 'a.something', 'a.a.a', 'something.ab', '123', '3speak', 'something.123', '-something', 'something-'].forEach(
(input) => {
it(`should return accountNameWrongSegment for invalid account name (${input})`, () => {
const actual = AccountNameValidator.validateAccountName(input, Localization.DEFAULT);
expect(actual).to.be.equal(Localization.DEFAULT.accountNameWrongSegment);
});
}
);
["engrave", "hive--blocks", "something.abc"].forEach((input) => {
['engrave', 'hive--blocks', 'something.abc'].forEach((input) => {
it(`should return null for valid account name (${input})`, () => {
const actual = AccountNameValidator.validateAccountName(input, DefaultRendererLocalization.DEFAULT);
const actual = AccountNameValidator.validateAccountName(input, Localization.DEFAULT);
expect(actual).to.be.equal(null);
});
});
});
\ No newline at end of file
});
/**
* Based on: https://raw.githubusercontent.com/openhive-network/condenser/master/src/app/utils/ChainValidation.js
*/
import { DefaultRendererLocalization } from "../../DefaultRendererLocalization";
import BadActorList from "./BadActorList";
import {LocalizationOptions} from '../../LocalizationOptions';
import BadActorList from './BadActorList';
export class AccountNameValidator {
// tslint:disable cyclomatic-complexity
public static validateAccountName(value: string, localization: DefaultRendererLocalization) {
public static validateAccountName(value: string, localization: LocalizationOptions) {
let i;
let label;
let len;
let length;
let ref;
if (!value) {
return localization.accountNameWrongLength;
}
length = value.length;
const length = value.length;
if (length < 3) {
return localization.accountNameWrongLength;
}
......@@ -27,7 +24,7 @@ export class AccountNameValidator {
if (BadActorList.includes(value)) {
return localization.accountNameBadActor;
}
ref = value.split(".");
const ref = value.split('.');
for (i = 0, len = ref.length; i < len; i++) {
label = ref[i];
if (!/^[a-z]/.test(label)) {
......
......@@ -5,13 +5,13 @@
const urlChar = '[^\\s"<>\\]\\[\\(\\)]';
const urlCharEnd = urlChar.replace(/\]$/, ".,']"); // insert bad chars to end on
const imagePath = "(?:(?:\\.(?:tiff?|jpe?g|gif|png|svg|ico)|ipfs/[a-z\\d]{40,}))";
const domainPath = "(?:[-a-zA-Z0-9\\._]*[-a-zA-Z0-9])";
const urlChars = "(?:" + urlChar + "*" + urlCharEnd + ")?";
const imagePath = '(?:(?:\\.(?:tiff?|jpe?g|gif|png|svg|ico)|ipfs/[a-z\\d]{40,}))';
const domainPath = '(?:[-a-zA-Z0-9\\._]*[-a-zA-Z0-9])';
const urlChars = '(?:' + urlChar + '*' + urlCharEnd + ')?';
const urlSet = ({ domain = domainPath, path = "" } = {}) => {
const urlSet = ({domain = domainPath, path = ''} = {}) => {
// urlChars is everything but html or markdown stop chars
return `https?:\/\/${domain}(?::\\d{2,5})?(?:[/\\?#]${urlChars}${path ? path : ""})${path ? "" : "?"}`;
return `https?://${domain}(?::\\d{2,5})?(?:[/\\?#]${urlChars}${path ? path : ''})${path ? '' : '?'}`;
};
/**
......@@ -20,14 +20,13 @@ const urlSet = ({ domain = domainPath, path = "" } = {}) => {
* left off when called with the
* same string so naturally the regexp object can't be cached for long.
*/
export const any = (flags = "i") => new RegExp(urlSet(), flags);
export const any = (flags = 'i') => new RegExp(urlSet(), flags);
// TODO verify if we should pass baseUrl here
export const local = (flags = "i") => new RegExp(urlSet({ domain: "(?:localhost|(?:.*\\.)?hive.blog)" }), flags);
export const remote = (flags = "i") =>
new RegExp(urlSet({ domain: `(?!localhost|(?:.*\\.)?hive.blog)${domainPath}` }), flags);
export const youTube = (flags = "i") => new RegExp(urlSet({ domain: "(?:(?:.*.)?(youtube\\.com|youtu\\.be))" }), flags);
export const image = (flags = "i") => new RegExp(urlSet({ path: imagePath }), flags);
export const imageFile = (flags = "i") => new RegExp(imagePath, flags);
export const local = (flags = 'i') => new RegExp(urlSet({domain: '(?:localhost|(?:.*\\.)?hive.blog)'}), flags);
export const remote = (flags = 'i') => new RegExp(urlSet({domain: `(?!localhost|(?:.*\\.)?hive.blog)${domainPath}`}), flags);
export const youTube = (flags = 'i') => new RegExp(urlSet({domain: '(?:(?:.*.)?(youtube\\.com|youtu\\.be))'}), flags);
export const image = (flags = 'i') => new RegExp(urlSet({path: imagePath}), flags);
export const imageFile = (flags = 'i') => new RegExp(imagePath, flags);
export default {
any: any(),
......@@ -36,9 +35,9 @@ export default {
image: image(),
imageFile: imageFile(),
youTube: youTube(),
youTubeId: /(?:(?:youtube.com\/watch\?v=)|(?:youtu.be\/)|(?:youtube.com\/embed\/))([A-Za-z0-9\_\-]+)/i,
youTubeId: /(?:youtube.com\/watch\?v=|(?:youtu.be\/)|(?:youtube.com\/embed\/))([A-Za-z0-9_-]+)/i,
vimeo: /https?:\/\/(?:vimeo.com\/|player.vimeo.com\/video\/)([0-9]+)\/*/,
vimeoId: /(?:vimeo.com\/|player.vimeo.com\/video\/)([0-9]+)/,
ipfsPrefix: /(https?:\/\/.*)?\/ipfs/i,
twitch: /https?:\/\/(?:www.)?twitch.tv\/(?:(videos)\/)?([a-zA-Z0-9][\w]{3,24})/i,
};
\ No newline at end of file
twitch: /https?:\/\/(?:www.)?twitch.tv\/(?:(videos)\/)?([a-zA-Z0-9][\w]{3,24})/i
};
// tslint:disable member-ordering
export abstract class AbstractVideoEmbedder {
public abstract markEmbedIfFound(textNode: HTMLObjectElement): { image?: string; link?: string } | undefined;
public abstract processEmbedIfRelevant(
embedType: string,
id: string,
size: { width: number; height: number },
htmlElementKey: string,
): string | undefined;
public abstract markEmbedIfFound(textNode: HTMLObjectElement): {image?: string; link?: string} | undefined;
public abstract processEmbedIfRelevant(embedType: string, id: string, size: {width: number; height: number}, htmlElementKey: string): string | undefined;
public static getEmbedMarker(id: string, type: string) {
return `~~~ embed:${id} ${type} ~~~`;
}
public static insertAllEmbeds(
embedders: AbstractVideoEmbedder[],
input: string,
size: { width: number; height: number },
): string {
public static insertAllEmbeds(embedders: AbstractVideoEmbedder[], input: string, size: {width: number; height: number}): string {
// In addition to inserting the youtube component, this allows
// react to compare separately preventing excessive re-rendering.
let idx = 0;
const sections = [];
// HtmlReady inserts ~~~ embed:${id} type ~~~
for (let section of input.split("~~~ embed:")) {
const match = section.match(/^([A-Za-z0-9\?\=\_\-]+) ([^ ]*) ~~~/);
for (let section of input.split('~~~ embed:')) {
const match = section.match(/^([A-Za-z0-9?=_-]+) ([^ ]*) ~~~/);
if (match && match.length >= 3) {
const id = match[1];
const type = match[2];
for (const embedder of embedders) {
const resp = embedder.processEmbedIfRelevant(type, id, size, idx++ + "");
const resp = embedder.processEmbedIfRelevant(type, id, size, idx++ + '');
if (resp) {
sections.push(resp);
break;
......@@ -38,12 +29,12 @@ export abstract class AbstractVideoEmbedder {
}
section = section.substring(`${id} ${type} ~~~`.length);
// section = section.substring(`${id} ${type} ~~~`.length);
if (section === "") {
if (section === '') {
continue;
}
}
sections.push(section);
}
return sections.join("");
return sections.join('');
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment