newFollow: this.i18n('You or your channel(s) has a new follower'),
commentMention: this.i18n('Someone mentioned you in video comments'),
newInstanceFollower: this.i18n('Your instance has a new follower'),
- autoInstanceFollowing: this.i18n('Your instance auto followed another instance')
+ autoInstanceFollowing: this.i18n('Your instance auto followed another instance'),
+ abuseNewMessage: this.i18n('An abuse received a new message'),
+ abuseStateChange: this.i18n('One of your abuse has been accepted or rejected by moderators')
}
this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
@Injectable()
export class HtmlRendererService {
+ private sanitizeHtml: typeof import ('sanitize-html')
constructor (private linkifier: LinkifierService) {
}
+ async convertToBr (text: string) {
+ await this.loadSanitizeHtml()
+
+ const html = text.replace(/\r?\n/g, '<br />')
+
+ return this.sanitizeHtml(html, {
+ allowedTags: [ 'br' ]
+ })
+ }
+
async toSafeHtml (text: string) {
- // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
- const sanitizeHtml: typeof import ('sanitize-html') = (await import('sanitize-html') as any).default
+ await this.loadSanitizeHtml()
// Convert possible markdown to html
const html = this.linkifier.linkify(text)
- return sanitizeHtml(html, {
+ return this.sanitizeHtml(html, {
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
allowedSchemes: [ 'http', 'https' ],
allowedAttributes: {
}
})
}
+
+ private async loadSanitizeHtml () {
+ // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
+ this.sanitizeHtml = (await import('sanitize-html') as any).default
+ }
}
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
<th i18n style="width: 80px;">Messages</th>
+ <th i18n *ngIf="isAdminView()" style="width: 100px;">Internal note</th>
<th style="width: 150px;"></th>
</tr>
</ng-template>
</ng-container>
-
<td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
<td class="c-hand abuse-states" [pRowToggler]="abuse">
<span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
<span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
- <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
</td>
<td class="c-hand abuse-messages" (click)="openAbuseMessagesModal(abuse)">
</ng-container>
</td>
+ <td *ngIf="isAdminView()" class="internal-note" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment">
+ {{ abuse.moderationComment }}
+ </td>
+
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
isDisplayed: abuse => this.isLocalAbuse(abuse)
},
{
- label: this.i18n('Update note'),
+ label: this.i18n('Update internal note'),
handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: abuse => this.isAdminView() && !!abuse.moderationComment
},
</div>
<div class="modal-body">
- <div class="messages" #messagesBlock>
+ <div class="messages">
<div
*ngFor="let message of abuseMessages"
class="message-block" [ngClass]="{ 'by-moderator': message.byModerator, 'by-me': isMessageByMe(message) }"
<div class="author">{{ message.account.name }}</div>
<div class="bubble">
- <div class="content">{{ message.message }}</div>
+ <div class="content" [innerHTML]="message.messageHtml"></div>
<div class="date">{{ message.createdAt | date }}</div>
</div>
</div>
display: flex;
flex-direction: column;
overflow-y: scroll;
+ max-height: 50vh;
}
.no-messages {
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
+import { AuthService, Notifier, HtmlRendererService } from '@app/core'
import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
})
export class AbuseMessageModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
- @ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef
@Input() isAdminView: boolean
@Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>()
- abuseMessages: AbuseMessage[] = []
+ abuseMessages: (AbuseMessage & { messageHtml: string })[] = []
textareaMessage: string
sendingMessage = false
noResults = false
private abuseValidatorsService: AbuseValidatorsService,
private modalService: NgbModal,
private i18n: I18n,
+ private htmlRenderer: HtmlRendererService,
private auth: AuthService,
private notifier: Notifier,
private abuseService: AbuseService
private loadMessages () {
this.abuseService.listAbuseMessages(this.abuse)
.subscribe(
- res => {
- this.abuseMessages = res.data
+ async res => {
+ this.abuseMessages = []
+
+ for (const m of res.data) {
+ this.abuseMessages.push(Object.assign(m, {
+ messageHtml: await this.htmlRenderer.convertToBr(m.message)
+ }))
+ }
+
this.noResults = this.abuseMessages.length === 0
setTimeout(() => {
- if (!this.messagesBlock) return
-
- const element = this.messagesBlock.nativeElement as HTMLElement
- element.scrollIntoView({ block: 'end', inline: 'nearest' })
+ // Don't use ViewChild: it is not supported inside a ng-template
+ const messagesBlock = document.querySelector('.messages')
+ messagesBlock.scroll(0, messagesBlock.scrollHeight)
})
},
+import {
+ AbuseState,
+ ActorInfo,
+ FollowState,
+ UserNotification as UserNotificationServer,
+ UserNotificationType,
+ VideoInfo,
+ UserRight
+} from '@shared/models'
import { Actor } from '../account/actor.model'
-import { ActorInfo, Avatar, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '@shared/models'
+import { AuthUser } from '@app/core'
export class UserNotification implements UserNotificationServer {
id: number
abuse?: {
id: number
+ state: AbuseState
video?: VideoInfo
videoUrl?: string
commentUrl?: any[]
abuseUrl?: string
+ abuseQueryParams?: { [id: string]: string } = {}
videoAutoBlacklistUrl?: string
accountUrl?: string
videoImportIdentifier?: string
videoImportUrl?: string
instanceFollowUrl?: string
- constructor (hash: UserNotificationServer) {
+ constructor (hash: UserNotificationServer, user: AuthUser) {
this.id = hash.id
this.type = hash.type
this.read = hash.read
case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
this.abuseUrl = '/admin/moderation/abuses/list'
+ this.abuseQueryParams.search = '#' + this.abuse.id
if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video)
else if (this.abuse.comment) this.commentUrl = this.buildCommentUrl(this.abuse.comment)
else if (this.abuse.account) this.accountUrl = this.buildAccountUrl(this.abuse.account)
break
+ case UserNotificationType.ABUSE_STATE_CHANGE:
+ this.abuseUrl = '/my-account/abuses'
+ this.abuseQueryParams.search = '#' + this.abuse.id
+ break
+
+ case UserNotificationType.ABUSE_NEW_MESSAGE:
+ this.abuseUrl = user.hasRight(UserRight.MANAGE_ABUSES)
+ ? '/admin/moderation/abuses/list'
+ : '/my-account/abuses'
+ this.abuseQueryParams.search = '#' + this.abuse.id
+ break
+
case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
// Backward compatibility where we did not assign videoBlacklist to this type of notification before
import { catchError, map, tap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket } from '@app/core'
+import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket, AuthService } from '@app/core'
import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models'
import { environment } from '../../../../environments/environment'
import { UserNotification } from './user-notification.model'
constructor (
private authHttp: HttpClient,
+ private auth: AuthService,
private restExtractor: RestExtractor,
private restService: RestService,
private userNotificationSocket: UserNotificationSocket
}
private formatNotification (notification: UserNotificationServer) {
- return new UserNotification(notification)
+ return new UserNotification(notification, this.auth.getUser())
}
}
<my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
<div class="message" *ngIf="notification.videoUrl" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.abuse.video.name }}</a>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl" [queryParams]="notification.abuseQueryParams">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.abuse.video.name }}</a>
</div>
<div class="message" *ngIf="notification.commentUrl" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new comment abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.abuse.comment.video.name }}</a>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl" [queryParams]="notification.abuseQueryParams">A new comment abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.abuse.comment.video.name }}</a>
</div>
<div class="message" *ngIf="notification.accountUrl" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new account abuse</a> has been created on account <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.abuse.account.displayName }}</a>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl" [queryParams]="notification.abuseQueryParams">A new account abuse</a> has been created on account <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.abuse.account.displayName }}</a>
</div>
<!-- Deleted entity associated to the abuse -->
<div class="message" *ngIf="!notification.videoUrl && !notification.commentUrl && !notification.accountUrl" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new abuse</a> has been created
+ <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl" [queryParams]="notification.abuseQueryParams">A new abuse</a> has been created
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.ABUSE_STATE_CHANGE">
+ <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl" [queryParams]="notification.abuseQueryParams">Your abuse {{ notification.abuse.id }}</a> has been
+ <ng-container *ngIf="isAccepted(notification)">accepted</ng-container>
+ <ng-container *ngIf="!isAccepted(notification)">rejected</ng-container>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.ABUSE_NEW_MESSAGE">
+ <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl" [queryParams]="notification.abuseQueryParams">Abuse {{ notification.abuse.id }}</a> has a new message
</div>
</ng-container>
import { Subject } from 'rxjs'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { ComponentPagination, hasMoreItems, Notifier } from '@app/core'
-import { UserNotificationType } from '@shared/models'
+import { UserNotificationType, AbuseState } from '@shared/models'
import { UserNotification } from './user-notification.model'
import { UserNotificationService } from './user-notification.service'
this.sortField = column
this.loadNotifications(true)
}
+
+ isAccepted (notification: UserNotification) {
+ return notification.abuse.state === AbuseState.ACCEPTED
+ }
}
npm run tsc -- --incremental --sourceMap
cp -r ./server/static ./server/assets ./dist/server
+cp -r "./server/lib/emails" "./dist/server/lib"
NODE_ENV=test node node_modules/.bin/concurrently -k \
"node_modules/.bin/nodemon --delay 1 --watch ./dist dist/server" \
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 520
+const LAST_MIGRATION_VERSION = 525
// ---------------------------------------------------------------------------
--- /dev/null
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+}): Promise<void> {
+ await utils.sequelize.query(`
+ CREATE TABLE IF NOT EXISTS "abuseMessage" (
+ "id" serial,
+ "message" text NOT NULL,
+ "byModerator" boolean NOT NULL,
+ "accountId" integer REFERENCES "account" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ "abuseId" integer NOT NULL REFERENCES "abuse" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ "createdAt" timestamp WITH time zone NOT NULL,
+ "updatedAt" timestamp WITH time zone NOT NULL,
+ PRIMARY KEY ("id")
+ );
+ `)
+
+ const notificationSettingColumns = [ 'abuseStateChange', 'abuseNewMessage' ]
+
+ for (const column of notificationSettingColumns) {
+ const data = {
+ type: Sequelize.INTEGER,
+ defaultValue: null,
+ allowNull: true
+ }
+ await utils.queryInterface.addColumn('userNotificationSetting', column, data)
+ }
+
+ {
+ const query = 'UPDATE "userNotificationSetting" SET "abuseStateChange" = 3, "abuseNewMessage" = 3'
+ await utils.sequelize.query(query)
+ }
+
+ for (const column of notificationSettingColumns) {
+ const data = {
+ type: Sequelize.INTEGER,
+ defaultValue: null,
+ allowNull: false
+ }
+ await utils.queryInterface.changeColumn('userNotificationSetting', column, data)
+ }
+}
+
+function down (options) {
+ throw new Error('Not implemented.')
+}
+
+export {
+ up,
+ down
+}
import { bunyanLogger, logger } from '../helpers/logger'
import { CONFIG, isEmailEnabled } from '../initializers/config'
import { WEBSERVER } from '../initializers/constants'
-import { MAbuseFull, MAbuseMessage, MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
+import { MAbuseFull, MAbuseMessage, MAccountDefault, MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
import { MCommentOwnerVideo, MVideo, MVideoAccountLight } from '../types/models/video'
import { JobQueue } from './job-queue'
? 'Report #' + abuse.id + ' has been accepted'
: 'Report #' + abuse.id + ' has been rejected'
+ const abuseUrl = WEBSERVER.URL + '/my-account/abuses?search=%23' + abuse.id
+
const action = {
text,
- url: WEBSERVER.URL + '/my-account/abuses?search=%23' + abuse.id
+ url: abuseUrl
}
const emailPayload: EmailPayload = {
locals: {
action,
abuseId: abuse.id,
+ abuseUrl,
isAccepted: abuse.state === AbuseState.ACCEPTED
}
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
- addAbuseNewMessageNotification (to: string[], options: { target: 'moderator' | 'reporter', abuse: MAbuseFull, message: MAbuseMessage }) {
- const { abuse, target, message } = options
+ addAbuseNewMessageNotification (
+ to: string[],
+ options: {
+ target: 'moderator' | 'reporter'
+ abuse: MAbuseFull
+ message: MAbuseMessage
+ accountMessage: MAccountDefault
+ }) {
+ const { abuse, target, message, accountMessage } = options
+
+ const text = 'New message on report #' + abuse.id
+ const abuseUrl = target === 'moderator'
+ ? WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
+ : WEBSERVER.URL + '/my-account/abuses?search=%23' + abuse.id
- const text = 'New message on abuse #' + abuse.id
const action = {
text,
- url: target === 'moderator'
- ? WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
- : WEBSERVER.URL + '/my-account/abuses?search=%23' + abuse.id
+ url: abuseUrl
}
const emailPayload: EmailPayload = {
to,
subject: text,
locals: {
+ abuseId: abuse.id,
abuseUrl: action.url,
+ messageAccountName: accountMessage.getDisplayName(),
messageText: message.message,
action
}
include ../common/mixins.pug
block title
- | New abuse message
+ | New message on abuse report
block content
p
- | A new message was created on #[a(href=WEBSERVER.URL) abuse ##{abuseId} on #{WEBSERVER.HOST}]
+ | A new message by #{messageAccountName} was posted on #[a(href=abuseUrl) abuse report ##{abuseId}] on #{WEBSERVER.HOST}
blockquote #{messageText}
br(style="display: none;")
include ../common/mixins.pug
block title
- | Abuse state changed
+ | Abuse report state changed
block content
p
- | #[a(href=abuseUrl) Your abuse ##{abuseId} on #{WEBSERVER.HOST}] has been #{isAccepted ? 'accepted' : 'rejected'}
+ | #[a(href=abuseUrl) Your abuse report ##{abuseId}] on #{WEBSERVER.HOST} has been #{isAccepted ? 'accepted' : 'rejected'}
import { isBlockedByServerOrAccount } from './blocklist'
import { Emailer } from './emailer'
import { PeerTubeSocket } from './peertube-socket'
+import { AccountModel } from '@server/models/account/account'
class Notifier {
})
}
- notifyOnAbuseMessage (abuse: MAbuseFull, message: AbuseMessageModel): void {
+ notifyOnAbuseMessage (abuse: MAbuseFull, message: MAbuseMessage): void {
this.notifyOfNewAbuseMessage(abuse, message)
.catch(err => {
logger.error('Cannot notify on new abuse %d message.', abuse.id, { err })
const url = this.getAbuseUrl(abuse)
logger.info('Notifying reporter and moderators of new abuse message on %s.', url)
+ const accountMessage = await AccountModel.load(message.accountId)
+
function settingGetter (user: MUserWithNotificationSetting) {
return user.NotificationSetting.abuseNewMessage
}
}
function emailSenderReporter (emails: string[]) {
- return Emailer.Instance.addAbuseNewMessageNotification(emails, { target: 'reporter', abuse, message })
+ return Emailer.Instance.addAbuseNewMessageNotification(emails, { target: 'reporter', abuse, message, accountMessage })
}
function emailSenderModerators (emails: string[]) {
- return Emailer.Instance.addAbuseNewMessageNotification(emails, { target: 'moderator', abuse, message })
+ return Emailer.Instance.addAbuseNewMessageNotification(emails, { target: 'moderator', abuse, message, accountMessage })
}
async function buildReporterOptions () {