"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.5",
"html-webpack-plugin": "^2.19.0",
+ "linkifyjs": "^2.1.5",
"lodash-es": "^4.17.4",
"markdown-it": "^8.4.0",
"ngx-bootstrap": "2.0.2",
--- /dev/null
+import { Injectable } from '@angular/core'
+import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
+import * as linkify from 'linkifyjs'
+import * as linkifyHtml from 'linkifyjs/html'
+
+@Injectable()
+export class LinkifierService {
+
+ static CLASSNAME = 'linkified'
+
+ private linkifyOptions = {
+ className: {
+ mention: LinkifierService.CLASSNAME + '-mention',
+ url: LinkifierService.CLASSNAME + '-url'
+ }
+ }
+
+ constructor () {
+ // Apply plugin
+ this.mentionWithDomainPlugin(linkify)
+ }
+
+ linkify (text: string) {
+ return linkifyHtml(text, this.linkifyOptions)
+ }
+
+ private mentionWithDomainPlugin (linkify: any) {
+ const TT = linkify.scanner.TOKENS // Text tokens
+ const { TOKENS: MT, State } = linkify.parser // Multi tokens, state
+ const MultiToken = MT.Base
+ const S_START = linkify.parser.start
+
+ const TT_AT = TT.AT
+ const TT_DOMAIN = TT.DOMAIN
+ const TT_LOCALHOST = TT.LOCALHOST
+ const TT_NUM = TT.NUM
+ const TT_COLON = TT.COLON
+ const TT_SLASH = TT.SLASH
+ const TT_TLD = TT.TLD
+ const TT_UNDERSCORE = TT.UNDERSCORE
+ const TT_DOT = TT.DOT
+
+ function MENTION (value) {
+ this.v = value
+ }
+
+ linkify.inherits(MultiToken, MENTION, {
+ type: 'mentionWithDomain',
+ isLink: true,
+ toHref () {
+ return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + this.toString().substr(1)
+ }
+ })
+
+ const S_AT = S_START.jump(TT_AT) // @
+ const S_AT_SYMS = new State()
+ const S_MENTION = new State(MENTION)
+ const S_MENTION_DIVIDER = new State()
+ const S_MENTION_DIVIDER_SYMS = new State()
+
+ // @_,
+ S_AT.on(TT_UNDERSCORE, S_AT_SYMS)
+
+ // @_*
+ S_AT_SYMS
+ .on(TT_UNDERSCORE, S_AT_SYMS)
+ .on(TT_DOT, S_AT_SYMS)
+
+ // Valid mention (not made up entirely of symbols)
+ S_AT
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+
+ S_AT_SYMS
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+
+ // More valid mentions
+ S_MENTION
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_COLON, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+ .on(TT_UNDERSCORE, S_MENTION)
+
+ // Mention with a divider
+ S_MENTION
+ .on(TT_AT, S_MENTION_DIVIDER)
+ .on(TT_SLASH, S_MENTION_DIVIDER)
+ .on(TT_DOT, S_MENTION_DIVIDER)
+
+ // Mention _ trailing stash plus syms
+ S_MENTION_DIVIDER.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
+ S_MENTION_DIVIDER_SYMS.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
+
+ // Once we get a word token, mentions can start up again
+ S_MENTION_DIVIDER
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+
+ S_MENTION_DIVIDER_SYMS
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+ }
+}
if (this.parentComment) {
const mentions = this.parentComments
- .filter(c => c.account.id !== this.user.account.id)
- .map(c => '@' + c.account.name)
+ .filter(c => c.account.id !== this.user.account.id) // Don't add mention of ourselves
+ .map(c => {
+ if (c.account.host) return '@' + c.account.name + '@' + c.account.host
+
+ return c.account.name
+ })
const mentionsSet = new Set(mentions)
const mentionsText = Array.from(mentionsSet).join(' ') + ' '
.comment-html {
word-break: break-all;
- a {
+ /deep/ a {
@include disable-default-a-behaviour;
color: #000;
+
+ // Semi bold mentions
+ &:not(.linkified-url) {
+ font-weight: $font-semibold;
+ }
}
}
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { MarkdownService } from '@app/videos/shared'
+import { LinkifierService } from '@app/videos/+video-watch/comment/linkifier.service'
import * as sanitizeHtml from 'sanitize-html'
import { Account as AccountInterface } from '../../../../../../shared/models/actors'
import { UserRight } from '../../../../../../shared/models/users'
newParentComments = []
constructor (
- private authService: AuthService,
- private markdownService: MarkdownService
+ private linkifierService: LinkifierService,
+ private authService: AuthService
) {}
get user () {
}
private init () {
- this.sanitizedCommentHTML = sanitizeHtml(this.comment.text, {
+ // Convert possible markdown to html
+ const html = this.linkifierService.linkify(this.comment.text)
+
+ this.sanitizedCommentHTML = sanitizeHtml(html, {
allowedTags: [ 'a', 'p', 'span', 'br' ],
- allowedSchemes: [ 'http', 'https' ]
+ allowedSchemes: [ 'http', 'https' ],
+ allowedAttributes: {
+ 'a': [ 'href', 'class' ]
+ },
+ transformTags: {
+ a: (tagName, attribs) => {
+ return {
+ tagName,
+ attribs: Object.assign(attribs, {
+ target: '_blank'
+ })
+ }
+ }
+ }
})
- // Convert possible markdown to html
- this.sanitizedCommentHTML = this.markdownService.linkify(this.comment.text)
-
this.newParentComments = this.parentComments.concat([ this.comment ])
}
}
import { NgModule } from '@angular/core'
+import { LinkifierService } from '@app/videos/+video-watch/comment/linkifier.service'
import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
import { TooltipModule } from 'ngx-bootstrap/tooltip'
import { ClipboardModule } from 'ngx-clipboard'
providers: [
MarkdownService,
+ LinkifierService,
VideoCommentService
]
})
@Injectable()
export class MarkdownService {
private textMarkdownIt: MarkdownIt.MarkdownIt
- private linkifier: MarkdownIt.MarkdownIt
private enhancedMarkdownIt: MarkdownIt.MarkdownIt
constructor () {
.enable('list')
.enable('image')
this.setTargetToLinks(this.enhancedMarkdownIt)
-
- this.linkifier = new MarkdownIt('zero', { linkify: true })
- .enable('linkify')
- this.setTargetToLinks(this.linkifier)
}
textMarkdownToHTML (markdown: string) {
return this.avoidTruncatedLinks(html)
}
- linkify (text: string) {
- const html = this.linkifier.render(text)
-
- return this.avoidTruncatedLinks(html)
- }
-
private setTargetToLinks (markdownIt: MarkdownIt.MarkdownIt) {
// Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) {
pify "^3.0.0"
serialize-javascript "^1.4.0"
+core-js@^1.0.0:
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
+
core-js@^2.4.0, core-js@^2.4.1:
version "2.5.3"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e"
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+encoding@^0.1.11:
+ version "0.1.12"
+ resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
+ dependencies:
+ iconv-lite "~0.4.13"
+
end-of-stream@^1.0.0, end-of-stream@^1.1.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
dependencies:
websocket-driver ">=0.5.1"
+fbjs@^0.8.16:
+ version "0.8.16"
+ resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
+ dependencies:
+ core-js "^1.0.0"
+ isomorphic-fetch "^2.1.1"
+ loose-envify "^1.0.0"
+ object-assign "^4.1.0"
+ promise "^7.1.1"
+ setimmediate "^1.0.5"
+ ua-parser-js "^0.7.9"
+
figures@^1.3.5:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
version "1.0.0"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
-iconv-lite@0.4.19:
+iconv-lite@0.4.19, iconv-lite@~0.4.13:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
-is-stream@^1.1.0:
+is-stream@^1.0.1, is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
version "3.0.1"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+isomorphic-fetch@^2.1.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
+ dependencies:
+ node-fetch "^1.0.1"
+ whatwg-fetch ">=0.10.0"
+
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
istanbul-lib-coverage "^1.1.1"
semver "^5.3.0"
+jquery@>=1.9.0:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
+
js-base64@^2.1.8, js-base64@^2.1.9:
version "2.4.3"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.3.tgz#2e545ec2b0f2957f41356510205214e98fad6582"
dependencies:
uc.micro "^1.0.1"
+linkifyjs@^2.1.5:
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.5.tgz#effc9f01e4aeafbbdbef21a45feab38b9516f93e"
+ optionalDependencies:
+ jquery ">=1.9.0"
+ react ">=0.14.0"
+ react-dom ">=0.14.0"
+
load-ip-set@^1.2.7:
version "1.3.1"
resolved "https://registry.yarnpkg.com/load-ip-set/-/load-ip-set-1.3.1.tgz#cfd050c6916e7ba0ca85d0b566e7854713eb495e"
version "1.0.1"
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
-loose-envify@^1.0.0:
+loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
dependencies:
dependencies:
semver "^5.4.1"
+node-fetch@^1.0.1:
+ version "1.7.3"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
+ dependencies:
+ encoding "^0.1.11"
+ is-stream "^1.0.1"
+
node-forge@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300"
dependencies:
asap "~2.0.3"
+prop-types@^15.6.0:
+ version "15.6.0"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
+ dependencies:
+ fbjs "^0.8.16"
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
proxy-addr@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
minimist "^1.2.0"
strip-json-comments "~2.0.1"
+react-dom@>=0.14.0:
+ version "16.2.0"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
+ dependencies:
+ fbjs "^0.8.16"
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+ prop-types "^15.6.0"
+
+react@>=0.14.0:
+ version "16.2.0"
+ resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
+ dependencies:
+ fbjs "^0.8.16"
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+ prop-types "^15.6.0"
+
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
is-plain-object "^2.0.3"
split-string "^3.0.1"
-setimmediate@^1.0.4:
+setimmediate@^1.0.4, setimmediate@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
version "2.6.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.6.2.tgz#3c5b6fd7f6de0914269027f03c0946758f7673a4"
+ua-parser-js@^0.7.9:
+ version "0.7.17"
+ resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
+
uc.micro@^1.0.1, uc.micro@^1.0.3:
version "1.0.5"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
xtend "^4.0.1"
zero-fill "^2.2.3"
+whatwg-fetch@>=0.10.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
+
when@~3.6.x:
version "3.6.4"
resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e"
import * as express from 'express'
import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers'
import { asyncMiddleware, oembedValidator } from '../middlewares'
+import { accountsNameWithHostGetValidator } from '../middlewares/validators'
import { VideoModel } from '../models/video/video'
const servicesRouter = express.Router()
asyncMiddleware(oembedValidator),
generateOEmbed
)
+servicesRouter.use('/redirect/accounts/:nameWithHost',
+ asyncMiddleware(accountsNameWithHostGetValidator),
+ redirectToAccountUrl
+)
// ---------------------------------------------------------------------------
return res.json(json)
}
+
+function redirectToAccountUrl (req: express.Request, res: express.Response, next: express.NextFunction) {
+ return res.redirect(res.locals.account.Actor.url)
+}
return isAccountExist(promise, res)
}
+function isAccountNameWithHostExist (nameWithDomain: string, res: Response) {
+ const [ accountName, host ] = nameWithDomain.split('@')
+
+ let promise: Bluebird<AccountModel>
+ if (!host) promise = AccountModel.loadLocalByName(accountName)
+ else promise = AccountModel.loadLocalByNameAndHost(accountName, host)
+
+ return isAccountExist(promise, res)
+}
+
async function isAccountExist (p: Bluebird<AccountModel>, res: Response) {
const account = await p
isAccountIdExist,
isLocalAccountNameExist,
isAccountDescriptionValid,
+ isAccountNameWithHostExist,
isAccountNameValid
}
)
}),
new winston.transports.Console({
- handleExcegiptions: true,
+ handleExceptions: true,
humanReadableUnhandledException: true,
format: winston.format.combine(
timestampFormatter,
import * as express from 'express'
import { param } from 'express-validator/check'
-import { isAccountIdExist, isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts'
+import {
+ isAccountIdExist,
+ isAccountNameValid,
+ isAccountNameWithHostExist,
+ isLocalAccountNameExist
+} from '../../helpers/custom-validators/accounts'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
}
]
+const accountsNameWithHostGetValidator = [
+ param('nameWithHost').exists().withMessage('Should have an account name with host'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking accountsNameWithHostGetValidator parameters', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isAccountNameWithHostExist(req.params.nameWithHost, res)) return
+
+ return next()
+ }
+]
+
// ---------------------------------------------------------------------------
export {
localAccountValidator,
- accountsGetValidator
+ accountsGetValidator,
+ accountsNameWithHostGetValidator
}
static loadLocalByName (name: string) {
const query = {
where: {
- name,
[ Sequelize.Op.or ]: [
{
userId: {
}
}
]
- }
+ },
+ include: [
+ {
+ model: ActorModel,
+ required: true,
+ where: {
+ preferredUsername: name
+ }
+ }
+ ]
+ }
+
+ return AccountModel.findOne(query)
+ }
+
+ static loadLocalByNameAndHost (name: string, host: string) {
+ const query = {
+ include: [
+ {
+ model: ActorModel,
+ required: true,
+ where: {
+ preferredUsername: name
+ },
+ include: [
+ {
+ model: ServerModel,
+ required: true,
+ where: {
+ host
+ }
+ }
+ ]
+ }
+ ]
}
return AccountModel.findOne(query)