Add masto verification link support

This commit is contained in:
Chocobozzz 2024-12-19 10:29:14 +01:00
parent 5b4c7fc20d
commit 9bacc48643
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
19 changed files with 163 additions and 63 deletions

View File

@ -61,7 +61,7 @@
<div class="form-group">
<label i18n for="instanceDescription">Description</label>
<div class="label-small-info">
<my-custom-markup-help></my-custom-markup-help>
<my-custom-markup-help supportRelMe="true"></my-custom-markup-help>
</div>
<my-markdown-textarea

View File

@ -26,10 +26,14 @@
<div class="form-group">
<label i18n for="description">Description</label>
<textarea
id="description" formControlName="description" class="form-control"
[ngClass]="{ 'input-error': formErrors['description'] }"
></textarea>
<my-help helpType="markdownText" supportRelMe="true"></my-help>
<my-markdown-textarea
inputId="description" formControlName="description" class="form-control"
markdownType="enhanced" [formError]="formErrors['description']" withEmoji="true" withHtml="true"
></my-markdown-textarea>
<div *ngIf="formErrors.description" class="form-error" role="alert">
{{ formErrors.description }}
</div>

View File

@ -5,6 +5,8 @@ import { Notifier, User, UserService } from '@app/core'
import { USER_DESCRIPTION_VALIDATOR, USER_DISPLAY_NAME_REQUIRED_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { MarkdownTextareaComponent } from '@app/shared/shared-forms/markdown-textarea.component'
import { HelpComponent } from '@app/shared/shared-main/buttons/help.component'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
@Component({
@ -12,7 +14,7 @@ import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
templateUrl: './my-account-profile.component.html',
styleUrls: [ './my-account-profile.component.scss' ],
standalone: true,
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent, HelpComponent, MarkdownTextareaComponent ]
})
export class MyAccountProfileComponent extends FormReactive implements OnInit {
@Input() user: User = null

View File

@ -1,3 +1,11 @@
<ng-container i18n>
<a class="text-decoration-underline" href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noreferrer noopener">Markdown compatible</a> that also supports <a class="text-decoration-underline" href="https://docs.joinpeertube.org/api/custom-client-markup" target="_blank" rel="noreferrer noopener">custom PeerTube HTML tags</a>
<a class="text-decoration-underline" href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noreferrer noopener">Markdown compatible</a> that also supports <a class="text-decoration-underline" href="https://docs.joinpeertube.org/api/custom-client-markup" target="_blank" rel="noreferrer noopener">custom PeerTube HTML tags</a>.
</ng-container>
@if (supportRelMe) {
<br />
<ng-container i18n>
<a href="https://docs.joinmastodon.org/user/profile/#verification" target="_blank" rel="noopener noreferrer">Mastodon verification link</a> is also supported.
</ng-container>
}

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core'
import { booleanAttribute, Component, Input } from '@angular/core'
@Component({
selector: 'my-custom-markup-help',
@ -6,4 +6,5 @@ import { Component } from '@angular/core'
standalone: true
})
export class CustomMarkupHelpComponent {
@Input({ transform: booleanAttribute }) supportRelMe = false
}

View File

@ -54,6 +54,8 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
@Input({ required: true }) inputId: string
@Input() dir: string
@Input({ transform: booleanAttribute }) withHtml = false
@Input({ transform: booleanAttribute }) withEmoji = false
@ViewChild('textarea') textareaElement: ElementRef
@ -163,9 +165,9 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
html = result
} else if (this.markdownType === 'text') {
html = await this.markdownService.textMarkdownToHTML({ markdown: text, withEmoji: this.withEmoji })
html = await this.markdownService.textMarkdownToHTML({ markdown: text, withEmoji: this.withEmoji, withHtml: this.withHtml })
} else if (this.markdownType === 'enhanced') {
html = await this.markdownService.enhancedMarkdownToHTML({ markdown: text, withEmoji: this.withEmoji })
html = await this.markdownService.enhancedMarkdownToHTML({ markdown: text, withEmoji: this.withEmoji, withHtml: this.withHtml })
} else if (this.markdownType === 'to-unsafe-html') {
html = await this.markdownService.markdownToUnsafeHTML({ markdown: text })
}

View File

@ -1,9 +1,19 @@
import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core'
import { NgIf, NgTemplateOutlet } from '@angular/common'
import {
AfterContentInit,
booleanAttribute,
Component,
ContentChildren,
Input,
OnChanges,
OnInit,
QueryList,
TemplateRef
} from '@angular/core'
import { GlobalIconName } from '@app/shared/shared-icons/global-icon.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { ENHANCED_RULES, TEXT_RULES } from '@peertube/peertube-core-utils'
import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { NgIf, NgTemplateOutlet } from '@angular/common'
import { PeerTubeTemplateDirective } from '../common/peertube-template.directive'
@Component({
@ -20,6 +30,7 @@ export class HelpComponent implements OnInit, OnChanges, AfterContentInit {
@Input() iconName: GlobalIconName = 'help'
@Input() title = $localize`Get help`
@Input() autoClose = 'outside'
@Input({ transform: booleanAttribute }) supportRelMe = false
@ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'preHtml' | 'customHtml' | 'postHtml'>>
@ -76,9 +87,17 @@ export class HelpComponent implements OnInit, OnChanges, AfterContentInit {
}
private formatMarkdownSupport (rules: string[]) {
/* eslint-disable max-len */
return $localize`<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> compatible that supports:` +
let str =
// eslint-disable-next-line max-len
$localize`<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> compatible that supports:` +
this.createMarkdownList(rules)
if (this.supportRelMe) {
// eslint-disable-next-line max-len
str += $localize`<a href="https://docs.joinmastodon.org/user/profile/#verification" target="_blank" rel="noopener noreferrer">Mastodon verification link</a> is also supported.`
}
return str
}
private createMarkdownList (rules: string[]) {

View File

@ -30,10 +30,8 @@ export class SupportModalComponent {
const support = this.video?.support || this.videoChannel.support
this.markdownService.enhancedMarkdownToHTML({ markdown: support })
.then(r => {
this.htmlSupport = r
})
this.markdownService.enhancedMarkdownToHTML({ markdown: support, withEmoji: true, withHtml: true })
.then(r => this.htmlSupport = r)
this.displayName = this.video
? this.video.channel.displayName

View File

@ -43,7 +43,7 @@
<div class="form-group">
<label i18n for="display-name">Display name</label>
<input
type="text" id="display-name" class="form-control d-block"
type="text" id="display-name" class="form-control"
formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
>
<div *ngIf="formErrors['display-name']" class="form-error" role="alert">
@ -53,10 +53,15 @@
<div class="form-group">
<label i18n for="description">Description</label>
<textarea
id="description" formControlName="description" class="form-control d-block"
[ngClass]="{ 'input-error': formErrors['description'] }"
></textarea>
<my-help helpType="markdownText" supportRelMe="true"></my-help>
<my-markdown-textarea
inputId="description" formControlName="description" class="form-control"
markdownType="enhanced" [formError]="formErrors['description']" withEmoji="true" withHtml="true"
></my-markdown-textarea>
<div *ngIf="formErrors.description" class="form-error" role="alert">
{{ formErrors.description }}
</div>
@ -70,8 +75,8 @@
></my-help>
<my-markdown-textarea
inputId="support" formControlName="support" class="d-block"
markdownType="enhanced" [formError]="formErrors['support']"
inputId="support" formControlName="support" class="form-control"
markdownType="enhanced" [formError]="formErrors['support']" withEmoji="true" withHtml="true"
></my-markdown-textarea>
</div>

View File

@ -161,6 +161,7 @@
"memoizee": "^0.4.14",
"morgan": "^1.5.3",
"multer": "^1.4.5-lts.1",
"node-html-parser": "^6.1.13",
"node-media-server": "^2.1.4",
"nodemailer": "^6.0.0",
"opentelemetry-instrumentation-sequelize": "^0.41.0",

View File

@ -1,10 +1,12 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { config, expect } from 'chai'
import { Account, HttpStatusCode, VideoPlaylistCreateResult } from '@peertube/peertube-models'
import { cleanupTests, makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
import { getWatchPlaylistBasePaths, getWatchVideoBasePaths, prepareClientTests } from '@tests/shared/client.js'
config.truncateThreshold = 0
describe('Test Open Graph and Twitter cards HTML tags', function () {
let servers: PeerTubeServer[]
let account: Account
@ -239,6 +241,51 @@ describe('Test Open Graph and Twitter cards HTML tags', function () {
})
})
describe('Mastodon link', function () {
async function check (path: string, mastoLink: string, exist = true) {
const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
const expected = `<link href="${mastoLink}" rel="me">`
if (exist)expect(text).to.contain(expected)
else expect(text).to.not.contain(expected)
}
it('Should correctly include Mastodon link in account', async function () {
await servers[0].users.updateMe({
description: 'hi, please <a href="https://social.example.com/@username" rel="me">Follow me on Mastodon!</a>'
})
await check('/a/root', 'https://social.example.com/@username')
})
it('Should correctly include Mastodon link in channel', async function () {
await servers[0].channels.update({
channelName: 'root_channel',
attributes: {
description: '<a rel="me" href="https://social.example.com/@username2">Follow me on Mastodon!</a>'
}
})
await check('/c/root_channel', 'https://social.example.com/@username2')
})
it('Should correctly include Mastodon link on homepage', async function () {
await servers[0].config.updateExistingConfig({
newConfig: {
instance: {
description: '<a>toto</a>coucou<a rel="me" href="https://social.example.com/@username3">Follow me on Mastodon!</a>'
}
}
})
await check('/', 'https://social.example.com/@username3')
await check('/about', 'https://social.example.com/@username3', false)
})
})
after(async function () {
await cleanupTests(servers)
})

View File

@ -1,4 +1,4 @@
import { omit } from '@peertube/peertube-core-utils'
import { omit, pick } from '@peertube/peertube-core-utils'
import {
VideoPrivacy,
VideoPlaylistPrivacy,
@ -55,7 +55,7 @@ export async function prepareClientTests () {
await servers[0].config.updateExistingConfig({
newConfig: {
instance: { name: instanceConfig.name, shortDescription: instanceConfig.shortDescription }
instance: { ...pick(instanceConfig, [ 'name', 'shortDescription' ]) }
}
})
await servers[0].config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture: instanceConfig.avatar })

View File

@ -2,7 +2,7 @@ import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings/index.js'
import { maxBy, pick } from '@peertube/peertube-core-utils'
import { ActorImageType } from '@peertube/peertube-models'
import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
import { mdToPlainText } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { UserModel } from '@server/models/user/user.js'
@ -35,7 +35,7 @@ export function initFeed (parameters: {
return new Feed({
title: name,
description: mdToOneLinePlainText(description),
description: mdToPlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: link || webserverUrl,
link: link || webserverUrl,

View File

@ -1,5 +1,5 @@
import { VideoIncludeType } from '@peertube/peertube-models'
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown.js'
import { mdToPlainText, toSafeHtml } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { getServerActor } from '@server/models/application/application.js'
@ -47,7 +47,7 @@ export function getCommonVideoFeedAttributes (video: VideoModel) {
return {
title: video.name,
link: localLink,
description: mdToOneLinePlainText(video.getTruncatedDescription()),
description: mdToPlainText(video.getTruncatedDescription()),
content: toSafeHtml(video.description),
date: video.publishedAt,

View File

@ -29,7 +29,7 @@ const toSafeHtml = (text: string) => {
return sanitizeHtml(html, defaultSanitizeOptions)
}
const mdToOneLinePlainText = (text: string) => {
const mdToPlainText = (text: string) => {
if (!text) return ''
markdownItForPlainText.render(text)
@ -42,7 +42,7 @@ const mdToOneLinePlainText = (text: string) => {
export {
toSafeHtml,
mdToOneLinePlainText
mdToPlainText
}
// ---------------------------------------------------------------------------

View File

@ -75,6 +75,7 @@ export class ActorHtml {
escapedTitle: escapeHTML(title),
escapedSiteName: escapeHTML(siteName),
escapedTruncatedDescription,
relMe: TagsHtml.findRelMe(entity.description),
image,
ogType,
twitterCard,

View File

@ -42,6 +42,10 @@ export class PageHtml {
escapedTitle: escapeHTML(CONFIG.INSTANCE.NAME),
escapedTruncatedDescription: escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION),
relMe: url === WEBSERVER.URL
? TagsHtml.findRelMe(CONFIG.INSTANCE.DESCRIPTION)
: undefined,
image: avatar
? { url: ActorImageModel.getImageUrl(avatar), width: avatar.width, height: avatar.height }
: undefined,

View File

@ -1,10 +1,11 @@
import { escapeAttribute, escapeHTML } from '@peertube/peertube-core-utils'
import { mdToPlainText } from '@server/helpers/markdown.js'
import truncate from 'lodash-es/truncate.js'
import { CONFIG } from '../../../initializers/config.js'
import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, WEBSERVER } from '../../../initializers/constants.js'
import { MVideo, MVideoPlaylist } from '../../../types/models/index.js'
import { Hooks } from '../../plugins/hooks.js'
import truncate from 'lodash-es/truncate.js'
import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
import { parse } from 'node-html-parser';
type Tags = {
forbidIndexation: boolean
@ -29,6 +30,8 @@ type Tags = {
escapedTitle?: string
escapedTruncatedDescription?: string
relMe?: string
image?: {
url: string
width: number
@ -68,15 +71,25 @@ export class TagsHtml {
return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
}
static findRelMe (content: string) {
if (!content) return undefined
const html = parse(content)
return html.querySelector('a[rel=me]')?.getAttribute('href') || undefined
}
// ---------------------------------------------------------------------------
static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
const openGraphMetaTags = this.generateOpenGraphMetaTagsOptions(tagsValues)
const standardMetaTags = this.generateStandardMetaTagsOptions(tagsValues)
const twitterCardMetaTags = this.generateTwitterCardMetaTagsOptions(tagsValues)
const metaTags = {
...this.generateOpenGraphMetaTagsOptions(tagsValues),
...this.generateStandardMetaTagsOptions(tagsValues),
...this.generateTwitterCardMetaTagsOptions(tagsValues)
}
const schemaTags = await this.generateSchemaTagsOptions(tagsValues, context)
const { url, escapedTitle, oembedUrl, forbidIndexation } = tagsValues
const { url, escapedTitle, oembedUrl, forbidIndexation, relMe } = tagsValues
const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
@ -90,29 +103,12 @@ export class TagsHtml {
let tagsStr = ''
// Opengraph
Object.keys(openGraphMetaTags).forEach(tagName => {
const tagValue = openGraphMetaTags[tagName]
if (!tagValue) return
for (const tagName of Object.keys(metaTags)) {
const tagValue = metaTags[tagName]
if (!tagValue) continue
tagsStr += `<meta property="${tagName}" content="${escapeAttribute(tagValue)}" />`
})
// Standard
Object.keys(standardMetaTags).forEach(tagName => {
const tagValue = standardMetaTags[tagName]
if (!tagValue) return
tagsStr += `<meta property="${tagName}" content="${escapeAttribute(tagValue)}" />`
})
// Twitter card
Object.keys(twitterCardMetaTags).forEach(tagName => {
const tagValue = twitterCardMetaTags[tagName]
if (!tagValue) return
tagsStr += `<meta property="${tagName}" content="${escapeAttribute(tagValue)}" />`
})
}
// OEmbed
for (const oembedLinkTag of oembedLinkTags) {
@ -125,6 +121,10 @@ export class TagsHtml {
tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
}
if (relMe) {
tagsStr += `<link href="${escapeAttribute(relMe)}" rel="me">`
}
// SEO, use origin URL
if (forbidIndexation !== true && url) {
tagsStr += `<link rel="canonical" href="${url}" />`
@ -261,6 +261,6 @@ export class TagsHtml {
// ---------------------------------------------------------------------------
static buildEscapedTruncatedDescription (description: string) {
return truncate(mdToOneLinePlainText(description), { length: 200 })
return truncate(mdToPlainText(description), { length: 200 })
}
}

View File

@ -8126,6 +8126,14 @@ node-gyp-build@^4.2.0, node-gyp-build@^4.3.0, node-gyp-build@^4.8.2:
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8"
integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==
node-html-parser@^6.1.13:
version "6.1.13"
resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-6.1.13.tgz#a1df799b83df5c6743fcd92740ba14682083b7e4"
integrity sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==
dependencies:
css-select "^5.1.0"
he "1.2.0"
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"