Add migrations for abuse messages

This commit is contained in:
Chocobozzz 2020-07-28 09:57:16 +02:00 committed by Chocobozzz
parent 594d3e48d8
commit d573926e9b
18 changed files with 189 additions and 43 deletions

View File

@ -42,7 +42,9 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
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)[]

View File

@ -3,19 +3,29 @@ import { LinkifierService } from './linkifier.service'
@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: {
@ -37,4 +47,9 @@ export class HtmlRendererService {
}
})
}
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
}
}

View File

@ -42,6 +42,7 @@
<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>
@ -144,13 +145,11 @@
</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)">
@ -161,6 +160,10 @@
</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"

View File

@ -278,7 +278,7 @@ export class AbuseListTableComponent extends RestTable implements OnInit, AfterV
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
},

View File

@ -9,7 +9,7 @@
</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) }"
@ -18,7 +18,7 @@
<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>

View File

@ -20,6 +20,7 @@ textarea {
display: flex;
flex-direction: column;
overflow-y: scroll;
max-height: 50vh;
}
.no-messages {

View File

@ -1,5 +1,5 @@
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'
@ -14,13 +14,12 @@ import { AbuseService } from '../shared-moderation'
})
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
@ -33,6 +32,7 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
private abuseValidatorsService: AbuseValidatorsService,
private modalService: NgbModal,
private i18n: I18n,
private htmlRenderer: HtmlRendererService,
private auth: AuthService,
private notifier: Notifier,
private abuseService: AbuseService
@ -108,15 +108,21 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
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)
})
},

View File

@ -1,5 +1,14 @@
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
@ -27,6 +36,7 @@ export class UserNotification implements UserNotificationServer {
abuse?: {
id: number
state: AbuseState
video?: VideoInfo
@ -69,13 +79,14 @@ export class UserNotification implements UserNotificationServer {
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
@ -122,12 +133,25 @@ export class UserNotification implements UserNotificationServer {
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

View File

@ -1,7 +1,7 @@
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'
@ -14,6 +14,7 @@ export class UserNotificationService {
constructor (
private authHttp: HttpClient,
private auth: AuthService,
private restExtractor: RestExtractor,
private restService: RestService,
private userNotificationSocket: UserNotificationSocket
@ -84,6 +85,6 @@ export class UserNotificationService {
}
private formatNotification (notification: UserNotificationServer) {
return new UserNotification(notification)
return new UserNotification(notification, this.auth.getUser())
}
}

View File

@ -46,20 +46,38 @@
<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>

View File

@ -1,7 +1,7 @@
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'
@ -116,4 +116,8 @@ export class UserNotificationsComponent implements OnInit {
this.sortField = column
this.loadNotifications(true)
}
isAccepted (notification: UserNotification) {
return notification.abuse.state === AbuseState.ACCEPTED
}
}

View File

@ -21,6 +21,7 @@ cp "./tsconfig.json" "./dist"
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" \

View File

@ -23,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 520
const LAST_MIGRATION_VERSION = 525
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,54 @@
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
}

View File

@ -11,7 +11,7 @@ import { isTestInstance, root } from '../helpers/core-utils'
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'
@ -362,9 +362,11 @@ class Emailer {
? '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 = {
@ -374,6 +376,7 @@ class Emailer {
locals: {
action,
abuseId: abuse.id,
abuseUrl,
isAccepted: abuse.state === AbuseState.ACCEPTED
}
}
@ -381,15 +384,24 @@ class Emailer {
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 = {
@ -397,7 +409,9 @@ class Emailer {
to,
subject: text,
locals: {
abuseId: abuse.id,
abuseUrl: action.url,
messageAccountName: accountMessage.getDisplayName(),
messageText: message.message,
action
}

View File

@ -2,10 +2,10 @@ extends ../common/greetings
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;")

View File

@ -2,8 +2,8 @@ extends ../common/greetings
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'}

View File

@ -24,6 +24,7 @@ import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../type
import { isBlockedByServerOrAccount } from './blocklist'
import { Emailer } from './emailer'
import { PeerTubeSocket } from './peertube-socket'
import { AccountModel } from '@server/models/account/account'
class Notifier {
@ -137,7 +138,7 @@ 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 })
@ -436,6 +437,8 @@ class Notifier {
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
}
@ -452,11 +455,11 @@ class Notifier {
}
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 () {