forked from AkkomaGang/akkoma-fe
d36b45ad43
In January 2020 Pleroma backend stopped escaping HTML in display names and passed that responsibility on frontends, compliant with Mastodon's version of Mastodon API [1]. Pleroma-FE was subsequently modified to escape the display name [2], however only in the "name_html" field. This was fine however, since that's what the code rendering display names used. However, 2 months ago an MR [3] refactoring the way the frontend does emoji and mention rendering was merged. One of the things it did was moving away from doing emoji rendering in the entity normalizer and use the unescaped 'user.name' in the rendering code, resulting in HTML injection being possible again. This patch escapes 'user.name' as well, as far as I can tell there is no actual use for an unescaped display name in frontend code, especially when it comes from MastoAPI, where it is not supposed to be HTML. [1]: https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1052 [2]: https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2167 [3]: https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1392
453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
import escape from 'escape-html'
|
|
import parseLinkHeader from 'parse-link-header'
|
|
import { isStatusNotification } from '../notification_utils/notification_utils.js'
|
|
import punycode from 'punycode.js'
|
|
|
|
/** NOTICE! **
|
|
* Do not initialize UI-generated data here.
|
|
* It will override existing data.
|
|
*
|
|
* i.e. user.pinnedStatusIds was set to [] here
|
|
* UI code would update it with data but upon next user fetch
|
|
* it would be reverted back to []
|
|
*/
|
|
|
|
const qvitterStatusType = (status) => {
|
|
if (status.is_post_verb) {
|
|
return 'status'
|
|
}
|
|
|
|
if (status.retweeted_status) {
|
|
return 'retweet'
|
|
}
|
|
|
|
if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) ||
|
|
(typeof status.text === 'string' && status.text.match(/favorited/))) {
|
|
return 'favorite'
|
|
}
|
|
|
|
if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) {
|
|
return 'deletion'
|
|
}
|
|
|
|
if (status.text.match(/started following/) || status.activity_type === 'follow') {
|
|
return 'follow'
|
|
}
|
|
|
|
return 'unknown'
|
|
}
|
|
|
|
export const parseUser = (data) => {
|
|
const output = {}
|
|
const masto = data.hasOwnProperty('acct')
|
|
// case for users in "mentions" property for statuses in MastoAPI
|
|
const mastoShort = masto && !data.hasOwnProperty('avatar')
|
|
|
|
output.id = String(data.id)
|
|
|
|
if (masto) {
|
|
output.screen_name = data.acct
|
|
output.statusnet_profile_url = data.url
|
|
|
|
// There's nothing else to get
|
|
if (mastoShort) {
|
|
return output
|
|
}
|
|
|
|
output.emoji = data.emojis
|
|
output.name = escape(data.display_name)
|
|
output.name_html = output.name
|
|
output.name_unescaped = data.display_name
|
|
|
|
output.description = data.note
|
|
// TODO cleanup this shit, output.description is overriden with source data
|
|
output.description_html = data.note
|
|
|
|
output.fields = data.fields
|
|
output.fields_html = data.fields.map(field => {
|
|
return {
|
|
name: escape(field.name),
|
|
value: field.value
|
|
}
|
|
})
|
|
output.fields_text = data.fields.map(field => {
|
|
return {
|
|
name: unescape(field.name.replace(/<[^>]*>/g, '')),
|
|
value: unescape(field.value.replace(/<[^>]*>/g, ''))
|
|
}
|
|
})
|
|
|
|
// Utilize avatar_static for gif avatars?
|
|
output.profile_image_url = data.avatar
|
|
output.profile_image_url_original = data.avatar
|
|
|
|
// Same, utilize header_static?
|
|
output.cover_photo = data.header
|
|
|
|
output.friends_count = data.following_count
|
|
|
|
output.bot = data.bot
|
|
|
|
if (data.pleroma) {
|
|
const relationship = data.pleroma.relationship
|
|
|
|
output.background_image = data.pleroma.background_image
|
|
output.favicon = data.pleroma.favicon
|
|
output.token = data.pleroma.chat_token
|
|
|
|
if (relationship) {
|
|
output.relationship = relationship
|
|
}
|
|
|
|
output.allow_following_move = data.pleroma.allow_following_move
|
|
|
|
output.hide_follows = data.pleroma.hide_follows
|
|
output.hide_followers = data.pleroma.hide_followers
|
|
output.hide_follows_count = data.pleroma.hide_follows_count
|
|
output.hide_followers_count = data.pleroma.hide_followers_count
|
|
|
|
output.rights = {
|
|
moderator: data.pleroma.is_moderator,
|
|
admin: data.pleroma.is_admin
|
|
}
|
|
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
|
|
if (output.rights.admin) {
|
|
output.role = 'admin'
|
|
} else if (output.rights.moderator) {
|
|
output.role = 'moderator'
|
|
} else {
|
|
output.role = 'member'
|
|
}
|
|
}
|
|
|
|
if (data.source) {
|
|
output.description = data.source.note
|
|
output.default_scope = data.source.privacy
|
|
output.fields = data.source.fields
|
|
if (data.source.pleroma) {
|
|
output.no_rich_text = data.source.pleroma.no_rich_text
|
|
output.show_role = data.source.pleroma.show_role
|
|
output.discoverable = data.source.pleroma.discoverable
|
|
}
|
|
}
|
|
|
|
// TODO: handle is_local
|
|
output.is_local = !output.screen_name.includes('@')
|
|
} else {
|
|
output.screen_name = data.screen_name
|
|
|
|
output.name = data.name
|
|
output.name_html = data.name_html
|
|
|
|
output.description = data.description
|
|
output.description_html = data.description_html
|
|
|
|
output.profile_image_url = data.profile_image_url
|
|
output.profile_image_url_original = data.profile_image_url_original
|
|
|
|
output.cover_photo = data.cover_photo
|
|
|
|
output.friends_count = data.friends_count
|
|
|
|
// output.bot = ??? missing
|
|
|
|
output.statusnet_profile_url = data.statusnet_profile_url
|
|
|
|
output.is_local = data.is_local
|
|
output.role = data.role
|
|
output.show_role = data.show_role
|
|
|
|
if (data.rights) {
|
|
output.rights = {
|
|
moderator: data.rights.delete_others_notice,
|
|
admin: data.rights.admin
|
|
}
|
|
}
|
|
output.no_rich_text = data.no_rich_text
|
|
output.default_scope = data.default_scope
|
|
output.hide_follows = data.hide_follows
|
|
output.hide_followers = data.hide_followers
|
|
output.hide_follows_count = data.hide_follows_count
|
|
output.hide_followers_count = data.hide_followers_count
|
|
output.background_image = data.background_image
|
|
// Websocket token
|
|
output.token = data.token
|
|
|
|
// Convert relationsip data to expected format
|
|
output.relationship = {
|
|
muting: data.muted,
|
|
blocking: data.statusnet_blocking,
|
|
followed_by: data.follows_you,
|
|
following: data.following
|
|
}
|
|
}
|
|
|
|
output.created_at = new Date(data.created_at)
|
|
output.locked = data.locked
|
|
output.followers_count = data.followers_count
|
|
output.statuses_count = data.statuses_count
|
|
|
|
if (data.pleroma) {
|
|
output.follow_request_count = data.pleroma.follow_request_count
|
|
|
|
output.tags = data.pleroma.tags
|
|
|
|
// deactivated was changed to is_active in Pleroma 2.3.0
|
|
// so check if is_active is present
|
|
output.deactivated = typeof data.pleroma.is_active !== 'undefined'
|
|
? !data.pleroma.is_active // new backend
|
|
: data.pleroma.deactivated // old backend
|
|
|
|
output.notification_settings = data.pleroma.notification_settings
|
|
output.unread_chat_count = data.pleroma.unread_chat_count
|
|
}
|
|
|
|
output.tags = output.tags || []
|
|
output.rights = output.rights || {}
|
|
output.notification_settings = output.notification_settings || {}
|
|
|
|
// Convert punycode to unicode for UI
|
|
output.screen_name_ui = output.screen_name
|
|
if (output.screen_name && output.screen_name.includes('@')) {
|
|
const parts = output.screen_name.split('@')
|
|
let unicodeDomain = punycode.toUnicode(parts[1])
|
|
if (unicodeDomain !== parts[1]) {
|
|
// Add some identifier so users can potentially spot spoofing attempts:
|
|
// lain.com and xn--lin-6cd.com would appear identical otherwise.
|
|
unicodeDomain = '🌏' + unicodeDomain
|
|
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
|
|
}
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
export const parseAttachment = (data) => {
|
|
const output = {}
|
|
const masto = !data.hasOwnProperty('oembed')
|
|
|
|
if (masto) {
|
|
// Not exactly same...
|
|
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
|
|
output.meta = data.meta // not present in BE yet
|
|
output.id = data.id
|
|
} else {
|
|
output.mimetype = data.mimetype
|
|
// output.meta = ??? missing
|
|
}
|
|
|
|
output.url = data.url
|
|
output.large_thumb_url = data.preview_url
|
|
output.description = data.description
|
|
|
|
return output
|
|
}
|
|
|
|
export const parseStatus = (data) => {
|
|
const output = {}
|
|
const masto = data.hasOwnProperty('account')
|
|
|
|
if (masto) {
|
|
output.favorited = data.favourited
|
|
output.fave_num = data.favourites_count
|
|
|
|
output.repeated = data.reblogged
|
|
output.repeat_num = data.reblogs_count
|
|
|
|
output.bookmarked = data.bookmarked
|
|
|
|
output.type = data.reblog ? 'retweet' : 'status'
|
|
output.nsfw = data.sensitive
|
|
|
|
output.raw_html = data.content
|
|
output.emojis = data.emojis
|
|
|
|
output.tags = data.tags
|
|
|
|
if (data.pleroma) {
|
|
const { pleroma } = data
|
|
output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content
|
|
output.summary = pleroma.spoiler_text ? data.pleroma.spoiler_text['text/plain'] : data.spoiler_text
|
|
output.statusnet_conversation_id = data.pleroma.conversation_id
|
|
output.is_local = pleroma.local
|
|
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
|
|
output.thread_muted = pleroma.thread_muted
|
|
output.emoji_reactions = pleroma.emoji_reactions
|
|
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
|
|
} else {
|
|
output.text = data.content
|
|
output.summary = data.spoiler_text
|
|
}
|
|
|
|
output.in_reply_to_status_id = data.in_reply_to_id
|
|
output.in_reply_to_user_id = data.in_reply_to_account_id
|
|
output.replies_count = data.replies_count
|
|
|
|
if (output.type === 'retweet') {
|
|
output.retweeted_status = parseStatus(data.reblog)
|
|
}
|
|
|
|
output.summary_raw_html = escape(data.spoiler_text)
|
|
output.external_url = data.url
|
|
output.poll = data.poll
|
|
if (output.poll) {
|
|
output.poll.options = (output.poll.options || []).map(field => ({
|
|
...field,
|
|
title_html: escape(field.title)
|
|
}))
|
|
}
|
|
output.pinned = data.pinned
|
|
output.muted = data.muted
|
|
} else {
|
|
output.favorited = data.favorited
|
|
output.fave_num = data.fave_num
|
|
|
|
output.repeated = data.repeated
|
|
output.repeat_num = data.repeat_num
|
|
|
|
// catchall, temporary
|
|
// Object.assign(output, data)
|
|
|
|
output.type = qvitterStatusType(data)
|
|
|
|
if (data.nsfw === undefined) {
|
|
output.nsfw = isNsfw(data)
|
|
if (data.retweeted_status) {
|
|
output.nsfw = data.retweeted_status.nsfw
|
|
}
|
|
} else {
|
|
output.nsfw = data.nsfw
|
|
}
|
|
|
|
output.raw_html = data.statusnet_html
|
|
output.text = data.text
|
|
|
|
output.in_reply_to_status_id = data.in_reply_to_status_id
|
|
output.in_reply_to_user_id = data.in_reply_to_user_id
|
|
output.in_reply_to_screen_name = data.in_reply_to_screen_name
|
|
output.statusnet_conversation_id = data.statusnet_conversation_id
|
|
|
|
if (output.type === 'retweet') {
|
|
output.retweeted_status = parseStatus(data.retweeted_status)
|
|
}
|
|
|
|
output.summary = data.summary
|
|
output.summary_html = data.summary_html
|
|
output.external_url = data.external_url
|
|
output.is_local = data.is_local
|
|
}
|
|
|
|
output.id = String(data.id)
|
|
output.visibility = data.visibility
|
|
output.card = data.card
|
|
output.created_at = new Date(data.created_at)
|
|
|
|
// Converting to string, the right way.
|
|
output.in_reply_to_status_id = output.in_reply_to_status_id
|
|
? String(output.in_reply_to_status_id)
|
|
: null
|
|
output.in_reply_to_user_id = output.in_reply_to_user_id
|
|
? String(output.in_reply_to_user_id)
|
|
: null
|
|
|
|
output.user = parseUser(masto ? data.account : data.user)
|
|
|
|
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(parseUser)
|
|
|
|
output.attachments = ((masto ? data.media_attachments : data.attachments) || [])
|
|
.map(parseAttachment)
|
|
|
|
const retweetedStatus = masto ? data.reblog : data.retweeted_status
|
|
if (retweetedStatus) {
|
|
output.retweeted_status = parseStatus(retweetedStatus)
|
|
}
|
|
|
|
output.favoritedBy = []
|
|
output.rebloggedBy = []
|
|
|
|
return output
|
|
}
|
|
|
|
export const parseNotification = (data) => {
|
|
const mastoDict = {
|
|
'favourite': 'like',
|
|
'reblog': 'repeat'
|
|
}
|
|
const masto = !data.hasOwnProperty('ntype')
|
|
const output = {}
|
|
|
|
if (masto) {
|
|
output.type = mastoDict[data.type] || data.type
|
|
output.seen = data.pleroma.is_seen
|
|
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
|
|
output.action = output.status // TODO: Refactor, this is unneeded
|
|
output.target = output.type !== 'move'
|
|
? null
|
|
: parseUser(data.target)
|
|
output.from_profile = parseUser(data.account)
|
|
output.emoji = data.emoji
|
|
} else {
|
|
const parsedNotice = parseStatus(data.notice)
|
|
output.type = data.ntype
|
|
output.seen = Boolean(data.is_seen)
|
|
output.status = output.type === 'like'
|
|
? parseStatus(data.notice.favorited_status)
|
|
: parsedNotice
|
|
output.action = parsedNotice
|
|
output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
|
|
}
|
|
|
|
output.created_at = new Date(data.created_at)
|
|
output.id = parseInt(data.id)
|
|
|
|
return output
|
|
}
|
|
|
|
const isNsfw = (status) => {
|
|
const nsfwRegex = /#nsfw/i
|
|
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
|
|
}
|
|
|
|
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
|
|
const flakeId = opts.flakeId
|
|
const parsedLinkHeader = parseLinkHeader(linkHeader)
|
|
if (!parsedLinkHeader) return
|
|
const maxId = parsedLinkHeader.next.max_id
|
|
const minId = parsedLinkHeader.prev.min_id
|
|
|
|
return {
|
|
maxId: flakeId ? maxId : parseInt(maxId, 10),
|
|
minId: flakeId ? minId : parseInt(minId, 10)
|
|
}
|
|
}
|
|
|
|
export const parseChat = (chat) => {
|
|
const output = {}
|
|
output.id = chat.id
|
|
output.account = parseUser(chat.account)
|
|
output.unread = chat.unread
|
|
output.lastMessage = parseChatMessage(chat.last_message)
|
|
output.updated_at = new Date(chat.updated_at)
|
|
return output
|
|
}
|
|
|
|
export const parseChatMessage = (message) => {
|
|
if (!message) { return }
|
|
if (message.isNormalized) { return message }
|
|
const output = message
|
|
output.id = message.id
|
|
output.created_at = new Date(message.created_at)
|
|
output.chat_id = message.chat_id
|
|
output.emojis = message.emojis
|
|
output.content = message.content
|
|
if (message.attachment) {
|
|
output.attachments = [parseAttachment(message.attachment)]
|
|
} else {
|
|
output.attachments = []
|
|
}
|
|
output.pending = !!message.pending
|
|
output.error = false
|
|
output.idempotency_key = message.idempotency_key
|
|
output.isNormalized = true
|
|
return output
|
|
}
|