From 29329d6c459f69ce9168f20cfb84c020e41c6e7a Mon Sep 17 00:00:00 2001 From: Chocobozzz <me@florianbigard.com> Date: Fri, 29 Mar 2024 14:25:03 +0100 Subject: [PATCH] Implement auto tag on comments and videos * Comments and videos can be automatically tagged using core rules or watched word lists * These tags can be used to automatically filter videos and comments * Introduce a new video comment policy where comments must be approved first * Comments may have to be approved if the user auto block them using core rules or watched word lists * Implement FEP-5624 to federate reply control policies --- apps/peertube-cli/src/peertube-upload.ts | 51 +- apps/peertube-cli/src/shared/cli.ts | 2 +- client/src/app/+admin/admin.component.ts | 12 + .../following-list/follow-modal.component.ts | 9 +- .../+admin/moderation/moderation.routes.ts | 13 + .../watched-words-list-admin.component.html | 10 + .../watched-words-list-admin.component.ts | 13 + .../video-comment-list.component.html | 107 +-- .../video-comment-list.component.scss | 51 -- .../comments/video-comment-list.component.ts | 198 +----- .../overview/videos/video-admin.service.ts | 7 +- .../overview/videos/video-list.component.html | 34 +- .../overview/videos/video-list.component.ts | 11 +- client/src/app/+admin/routes.ts | 4 +- .../automatic-tag.service.ts | 45 ++ ...y-account-auto-tag-policies.component.html | 15 + .../my-account-auto-tag-policies.component.ts | 71 ++ .../comments-on-my-videos.component.html | 7 + .../comments-on-my-videos.component.ts | 14 + ...-account-watched-words-list.component.html | 10 + ...my-account-watched-words-list.component.ts | 19 + .../app/+my-account/my-account.component.ts | 57 +- client/src/app/+my-account/routes.ts | 52 +- .../shared/video-edit.component.html | 12 +- .../shared/video-edit.component.ts | 71 +- .../comment/video-comment.component.html | 3 + .../shared/comment/video-comment.component.ts | 41 +- .../comment/video-comments.component.html | 12 +- .../comment/video-comments.component.ts | 71 +- client/src/app/core/server/server.service.ts | 19 + .../shared/form-validators/host-validators.ts | 24 +- .../form-validators/shared/validator-utils.ts | 18 + .../watched-words-list-validators.ts | 51 ++ .../misc/top-menu-dropdown.component.html | 54 +- .../misc/top-menu-dropdown.component.ts | 8 +- .../users/user-notification.model.ts | 7 + .../shared-main/video/video-details.model.ts | 7 +- .../shared-main/video/video-edit.model.ts | 15 +- .../shared-main/video/video-import.service.ts | 2 +- .../shared/shared-main/video/video.model.ts | 4 + .../shared/shared-main/video/video.service.ts | 2 +- .../batch-domains-modal.component.ts | 7 +- .../user-moderation-dropdown.component.ts | 2 +- ...eo-comment-list-admin-owner.component.html | 122 ++++ ...eo-comment-list-admin-owner.component.scss | 60 ++ ...ideo-comment-list-admin-owner.component.ts | 260 ++++++++ .../video-comment.model.ts | 16 +- .../video-comment.service.ts | 76 ++- .../video-playlist.service.ts | 2 +- .../user-notifications.component.html | 1 + ...ched-words-list-admin-owner.component.html | 86 +++ ...atched-words-list-admin-owner.component.ts | 138 ++++ ...tched-words-list-save-modal.component.html | 49 ++ ...tched-words-list-save-modal.component.scss | 6 + ...watched-words-list-save-modal.component.ts | 95 +++ .../watched-words-list.service.ts | 86 +++ client/src/root-helpers/string.ts | 6 + config/default.yaml | 3 +- config/production.yaml.example | 3 +- package.json | 2 + packages/core-utils/src/users/user-role.ts | 6 +- packages/models/src/activitypub/activity.ts | 20 +- packages/models/src/activitypub/context.ts | 4 +- .../objects/video-comment-object.ts | 5 + .../src/activitypub/objects/video-object.ts | 7 +- .../auto-tag-policies-export.ts | 5 + .../peertube-export-format/index.ts | 2 + .../user-video-history-export.ts | 4 +- .../video-export.model.ts | 6 +- .../watched-words-lists-export.ts | 10 + .../import-export/user-import-result.model.ts | 3 + .../automatic-tag-available.model.ts | 8 + .../moderation/automatic-tag-policy.enum.ts | 6 + ...ent-automatic-tag-policies-update.model.ts | 3 + .../comment-automatic-tag-policies.model.ts | 3 + packages/models/src/moderation/index.ts | 7 +- .../moderation/watched-words-list.model.ts | 9 + .../src/search/videos-common-query.model.ts | 6 +- .../models/src/server/server-config.model.ts | 6 +- .../src/users/user-notification.model.ts | 1 + packages/models/src/users/user-right.enum.ts | 7 +- packages/models/src/videos/comment/index.ts | 1 + .../comment/video-comment-policy.enum.ts | 7 + .../src/videos/comment/video-comment.model.ts | 12 +- .../models/src/videos/video-create.model.ts | 5 + .../models/src/videos/video-include.enum.ts | 3 +- .../models/src/videos/video-update.model.ts | 5 + packages/models/src/videos/video.model.ts | 12 +- .../src/moderation/automatic-tags-command.ts | 68 ++ .../server-commands/src/moderation/index.ts | 2 + .../src/moderation/watched-words-command.ts | 87 +++ packages/server-commands/src/server/server.ts | 26 +- .../src/videos/comments-command.ts | 110 +++- .../src/videos/videos-command.ts | 11 +- .../tests/src/api/check-params/auto-tags.ts | 137 ++++ packages/tests/src/api/check-params/index.ts | 8 +- packages/tests/src/api/check-params/live.ts | 19 +- .../api/check-params/video-channel-syncs.ts | 2 +- .../src/api/check-params/video-comments.ts | 162 +++-- .../src/api/check-params/video-imports.ts | 18 +- .../src/api/check-params/video-passwords.ts | 7 +- .../api/check-params/videos-common-filters.ts | 18 + packages/tests/src/api/check-params/videos.ts | 62 +- packages/tests/src/api/check-params/views.ts | 2 +- .../src/api/check-params/watched-words.ts | 254 ++++++++ packages/tests/src/api/live/live.ts | 3 +- .../src/api/moderation/automatic-tags.ts | 489 ++++++++++++++ .../src/api/moderation/comment-approval.ts | 552 ++++++++++++++++ packages/tests/src/api/moderation/index.ts | 2 + .../tests/src/api/moderation/watched-words.ts | 189 ++++++ .../notifications/comments-notifications.ts | 111 +++- .../tests/src/api/server/config-defaults.ts | 8 +- packages/tests/src/api/server/follows.ts | 8 +- packages/tests/src/api/server/handle-down.ts | 10 +- packages/tests/src/api/users/user-export.ts | 30 +- packages/tests/src/api/users/user-import.ts | 34 +- .../tests/src/api/videos/multiple-servers.ts | 18 +- .../tests/src/api/videos/single-server.ts | 8 +- .../tests/src/api/videos/video-comments.ts | 112 +++- packages/tests/src/feeds/feeds.ts | 109 +++- packages/tests/src/server-helpers/index.ts | 1 + packages/tests/src/server-helpers/regexp.ts | 60 ++ packages/tests/src/shared/import-export.ts | 24 +- packages/tests/src/shared/notifications.ts | 15 +- packages/tests/src/shared/videos.ts | 16 +- .../user-import-completed/html.pug | 6 + .../video-comment-new/html.pug | 5 + server/core/controllers/activitypub/client.ts | 40 +- server/core/controllers/api/automatic-tags.ts | 82 +++ server/core/controllers/api/index.ts | 8 +- server/core/controllers/api/users/me.ts | 58 +- server/core/controllers/api/videos/comment.ts | 104 +-- server/core/controllers/api/videos/update.ts | 20 +- server/core/controllers/api/watched-words.ts | 162 +++++ .../core/controllers/feeds/comment-feeds.ts | 12 +- server/core/helpers/activity-pub-utils.ts | 24 +- server/core/helpers/audit-logger.ts | 2 +- .../custom-validators/activitypub/activity.ts | 61 +- .../activitypub/video-comments.ts | 4 +- .../custom-validators/activitypub/videos.ts | 25 +- .../core/helpers/custom-validators/videos.ts | 9 +- .../custom-validators/watched-words.ts | 17 + server/core/helpers/query.ts | 3 +- server/core/helpers/regexp.ts | 20 +- .../core/initializers/checker-before-init.ts | 2 +- server/core/initializers/config.ts | 3 +- server/core/initializers/constants.ts | 20 + server/core/initializers/database.ts | 28 +- .../initializers/migrations/0840-auto-tags.ts | 122 ++++ .../lib/activitypub/process/process-create.ts | 30 +- .../lib/activitypub/process/process-delete.ts | 2 +- .../lib/activitypub/process/process-flag.ts | 2 +- .../process/process-reply-approval.ts | 45 ++ .../core/lib/activitypub/process/process.ts | 7 +- server/core/lib/activitypub/send/http.ts | 19 +- server/core/lib/activitypub/send/index.ts | 1 + .../core/lib/activitypub/send/send-create.ts | 16 +- .../core/lib/activitypub/send/send-delete.ts | 4 +- .../activitypub/send/send-reply-approval.ts | 36 + .../activitypub/send/shared/audience-utils.ts | 2 +- server/core/lib/activitypub/url.ts | 101 +-- server/core/lib/activitypub/video-comments.ts | 66 +- .../videos/shared/abstract-builder.ts | 28 +- .../lib/activitypub/videos/shared/creator.ts | 4 +- .../shared/object-to-model-attributes.ts | 4 +- server/core/lib/activitypub/videos/updater.ts | 5 +- .../lib/automatic-tags/automatic-tagger.ts | 142 ++++ .../core/lib/automatic-tags/automatic-tags.ts | 99 +++ .../job-queue/handlers/activitypub-cleaner.ts | 4 +- .../lib/job-queue/handlers/video-import.ts | 29 +- .../job-queue/handlers/video-live-ending.ts | 2 +- server/core/lib/local-video-creator.ts | 9 +- server/core/lib/notifier/notifier.ts | 10 + .../shared/comment/comment-mention.ts | 6 +- .../comment/new-comment-for-video-owner.ts | 18 +- server/core/lib/server-config-manager.ts | 7 +- server/core/lib/stat-manager.ts | 8 +- .../exporters/auto-tag-policies.ts | 18 + .../exporters/comments-exporter.ts | 4 +- .../lib/user-import-export/exporters/index.ts | 4 +- .../exporters/videos-exporter.ts | 7 +- .../exporters/watched-words-lists-exporter.ts | 23 + .../review-comments-tag-policies-importer.ts | 31 + .../importers/user-video-history-importer.ts | 10 +- .../importers/videos-importer.ts | 32 +- .../importers/watched-words-lists-importer.ts | 35 + .../lib/user-import-export/user-exporter.ts | 45 +- .../lib/user-import-export/user-importer.ts | 38 +- server/core/lib/video-comment.ts | 135 +++- server/core/lib/video-pre-import.ts | 13 +- server/core/lib/video.ts | 22 +- .../middlewares/validators/automatic-tags.ts | 45 ++ server/core/middlewares/validators/bulk.ts | 2 +- .../middlewares/validators/shared/users.ts | 47 +- .../middlewares/validators/shared/videos.ts | 4 - server/core/middlewares/validators/sort.ts | 2 + .../middlewares/validators/users/users.ts | 20 +- .../validators/videos/video-comments.ts | 172 +++-- .../middlewares/validators/videos/videos.ts | 16 +- .../middlewares/validators/watched-words.ts | 128 ++++ server/core/models/account/account.ts | 35 +- server/core/models/actor/actor-follow.ts | 2 +- .../account-automatic-tag-policy.ts | 96 +++ .../models/automatic-tag/automatic-tag.ts | 69 ++ .../automatic-tag/comment-automatic-tag.ts | 76 +++ .../automatic-tag/video-automatic-tag.ts | 76 +++ server/core/models/shared/model-builder.ts | 7 +- server/core/models/shared/query.ts | 34 +- server/core/models/shared/sql.ts | 33 +- .../user-notitication-list-query-builder.ts | 1 + server/core/models/user/user-notification.ts | 3 +- .../formatter/video-activity-pub-format.ts | 20 +- .../video/formatter/video-api-format.ts | 27 +- .../video-comment-list-query-builder.ts | 77 ++- .../comment/video-comment-table-attributes.ts | 24 +- .../shared/abstract-video-query-builder.ts | 18 + .../sql/video/shared/video-model-builder.ts | 26 + .../video/shared/video-table-attributes.ts | 11 +- .../sql/video/videos-id-list-query-builder.ts | 22 + .../video/videos-model-list-query-builder.ts | 12 +- server/core/models/video/tag.ts | 63 +- server/core/models/video/video-comment.ts | 205 ++++-- server/core/models/video/video-file.ts | 6 +- .../models/video/video-streaming-playlist.ts | 4 +- server/core/models/video/video.ts | 16 +- .../watched-words/watched-words-list.ts | 206 ++++++ server/core/types/express.d.ts | 5 +- .../account-automatic-tag-policy.ts | 3 + .../models/automatic-tag/automatic-tag.ts | 3 + .../automatic-tag/comment-automatic-tag.ts | 15 + .../core/types/models/automatic-tag/index.ts | 4 + .../automatic-tag/video-automatic-tag.ts | 15 + server/core/types/models/index.ts | 2 + .../types/models/user/user-notification.ts | 2 +- .../core/types/models/video/video-channel.ts | 9 +- .../core/types/models/video/video-comment.ts | 22 +- server/core/types/models/video/video.ts | 3 +- .../core/types/models/watched-words/index.ts | 1 + .../watched-words/watched-words-list.ts | 3 + support/doc/api/openapi.yaml | 614 +++++++++++++++++- yarn.lock | 5 + 241 files changed, 8090 insertions(+), 1399 deletions(-) create mode 100644 client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html create mode 100644 client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts create mode 100644 client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts create mode 100644 client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html create mode 100644 client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts create mode 100644 client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html create mode 100644 client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts create mode 100644 client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html create mode 100644 client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts create mode 100644 client/src/app/shared/form-validators/shared/validator-utils.ts create mode 100644 client/src/app/shared/form-validators/watched-words-list-validators.ts create mode 100644 client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html create mode 100644 client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss create mode 100644 client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list.service.ts create mode 100644 packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts create mode 100644 packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts create mode 100644 packages/models/src/moderation/automatic-tag-available.model.ts create mode 100644 packages/models/src/moderation/automatic-tag-policy.enum.ts create mode 100644 packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts create mode 100644 packages/models/src/moderation/comment-automatic-tag-policies.model.ts create mode 100644 packages/models/src/moderation/watched-words-list.model.ts create mode 100644 packages/models/src/videos/comment/video-comment-policy.enum.ts create mode 100644 packages/server-commands/src/moderation/automatic-tags-command.ts create mode 100644 packages/server-commands/src/moderation/watched-words-command.ts create mode 100644 packages/tests/src/api/check-params/auto-tags.ts create mode 100644 packages/tests/src/api/check-params/watched-words.ts create mode 100644 packages/tests/src/api/moderation/automatic-tags.ts create mode 100644 packages/tests/src/api/moderation/comment-approval.ts create mode 100644 packages/tests/src/api/moderation/watched-words.ts create mode 100644 packages/tests/src/server-helpers/regexp.ts create mode 100644 server/core/controllers/api/automatic-tags.ts create mode 100644 server/core/controllers/api/watched-words.ts create mode 100644 server/core/helpers/custom-validators/watched-words.ts create mode 100644 server/core/initializers/migrations/0840-auto-tags.ts create mode 100644 server/core/lib/activitypub/process/process-reply-approval.ts create mode 100644 server/core/lib/activitypub/send/send-reply-approval.ts create mode 100644 server/core/lib/automatic-tags/automatic-tagger.ts create mode 100644 server/core/lib/automatic-tags/automatic-tags.ts create mode 100644 server/core/lib/user-import-export/exporters/auto-tag-policies.ts create mode 100644 server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts create mode 100644 server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts create mode 100644 server/core/lib/user-import-export/importers/watched-words-lists-importer.ts create mode 100644 server/core/middlewares/validators/automatic-tags.ts create mode 100644 server/core/middlewares/validators/watched-words.ts create mode 100644 server/core/models/automatic-tag/account-automatic-tag-policy.ts create mode 100644 server/core/models/automatic-tag/automatic-tag.ts create mode 100644 server/core/models/automatic-tag/comment-automatic-tag.ts create mode 100644 server/core/models/automatic-tag/video-automatic-tag.ts create mode 100644 server/core/models/watched-words/watched-words-list.ts create mode 100644 server/core/types/models/automatic-tag/account-automatic-tag-policy.ts create mode 100644 server/core/types/models/automatic-tag/automatic-tag.ts create mode 100644 server/core/types/models/automatic-tag/comment-automatic-tag.ts create mode 100644 server/core/types/models/automatic-tag/index.ts create mode 100644 server/core/types/models/automatic-tag/video-automatic-tag.ts create mode 100644 server/core/types/models/watched-words/index.ts create mode 100644 server/core/types/models/watched-words/watched-words-list.ts diff --git a/apps/peertube-cli/src/peertube-upload.ts b/apps/peertube-cli/src/peertube-upload.ts index 6a5950883..357870449 100644 --- a/apps/peertube-cli/src/peertube-upload.ts +++ b/apps/peertube-cli/src/peertube-upload.ts @@ -1,9 +1,9 @@ +import { Command } from '@commander-js/extra-typings' +import { VideoCommentPolicy, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models' +import { PeerTubeServer } from '@peertube/peertube-server-commands' import { access, constants } from 'fs/promises' import { isAbsolute } from 'path' import { inspect } from 'util' -import { Command } from '@commander-js/extra-typings' -import { VideoPrivacy } from '@peertube/peertube-models' -import { PeerTubeServer } from '@peertube/peertube-server-commands' import { assignToken, buildServer, getServerCredentials, listOptions } from './shared/index.js' type UploadOptions = { @@ -14,13 +14,13 @@ type UploadOptions = { preview?: string file?: string videoName?: string - category?: string - licence?: string + category?: number + licence?: number language?: string - tags?: string + tags?: string[] nsfw?: true videoDescription?: string - privacy?: number + privacy?: VideoPrivacyType channelName?: string noCommentsEnabled?: true support?: string @@ -41,13 +41,13 @@ export function defineUploadProgram () { .option('--preview <previewPath>', 'Preview path') .option('-f, --file <file>', 'Video absolute file path') .option('-n, --video-name <name>', 'Video name') - .option('-c, --category <category_number>', 'Category number') - .option('-l, --licence <licence_number>', 'Licence number') + .option('-c, --category <category_number>', 'Category number', parseInt) + .option('-l, --licence <licence_number>', 'Licence number', parseInt) .option('-L, --language <language_code>', 'Language ISO 639 code (fr or en...)') .option('-t, --tags <tags>', 'Video tags', listOptions) .option('-N, --nsfw', 'Video is Not Safe For Work') .option('-d, --video-description <description>', 'Video description') - .option('-P, --privacy <privacy_number>', 'Privacy', parseInt) + .option('-P, --privacy <privacy_number>', 'Privacy', v => parseInt(v) as VideoPrivacyType) .option('-C, --channel-name <channel_name>', 'Channel name') .option('--no-comments-enabled', 'Disable video comments') .option('-s, --support <support>', 'Video support text') @@ -120,10 +120,9 @@ async function run (options: UploadOptions) { } } -async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions, defaultAttributes: any = {}) { +async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions) { const defaultBooleanAttributes = { nsfw: false, - commentsEnabled: true, downloadEnabled: true, waitTranscoding: true } @@ -133,25 +132,29 @@ async function buildVideoAttributesFromCommander (server: PeerTubeServer, option for (const key of Object.keys(defaultBooleanAttributes)) { if (options[key] !== undefined) { booleanAttributes[key] = options[key] - } else if (defaultAttributes[key] !== undefined) { - booleanAttributes[key] = defaultAttributes[key] } else { booleanAttributes[key] = defaultBooleanAttributes[key] } } const videoAttributes = { - name: options.videoName || defaultAttributes.name, - category: options.category || defaultAttributes.category || undefined, - licence: options.licence || defaultAttributes.licence || undefined, - language: options.language || defaultAttributes.language || undefined, - privacy: options.privacy || defaultAttributes.privacy || VideoPrivacy.PUBLIC, - support: options.support || defaultAttributes.support || undefined, - description: options.videoDescription || defaultAttributes.description || undefined, - tags: options.tags || defaultAttributes.tags || undefined - } + name: options.videoName, + category: options.category || undefined, + licence: options.licence || undefined, + language: options.language || undefined, + privacy: options.privacy || VideoPrivacy.PUBLIC, + support: options.support || undefined, + description: options.videoDescription || undefined, + tags: options.tags || undefined, - Object.assign(videoAttributes, booleanAttributes) + commentsPolicy: options.noCommentsEnabled !== undefined + ? options.noCommentsEnabled === true + ? VideoCommentPolicy.DISABLED + : VideoCommentPolicy.ENABLED + : undefined, + + ...booleanAttributes + } if (options.channelName) { const videoChannel = await server.channels.get({ channelName: options.channelName }) diff --git a/apps/peertube-cli/src/shared/cli.ts b/apps/peertube-cli/src/shared/cli.ts index 080eb8237..256f4bcdb 100644 --- a/apps/peertube-cli/src/shared/cli.ts +++ b/apps/peertube-cli/src/shared/cli.ts @@ -120,7 +120,7 @@ function getRemoteObjectOrDie ( return { url, username, password } } -function listOptions (val: any) { +function listOptions (val: string) { return val.split(',') } diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts index 766a4ec52..cb3b0a262 100644 --- a/client/src/app/+admin/admin.component.ts +++ b/client/src/app/+admin/admin.component.ts @@ -153,6 +153,14 @@ export class AdminComponent implements OnInit { }) } + if (this.hasServerWatchedWordsRight()) { + moderationItems.children.push({ + label: $localize`Watched words`, + routerLink: '/admin/moderation/watched-words/list', + iconName: 'eye-open' + }) + } + if (moderationItems.children.length !== 0) this.menuEntries.push(moderationItems) } @@ -241,6 +249,10 @@ export class AdminComponent implements OnInit { return this.auth.getUser().hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST) } + private hasServerWatchedWordsRight () { + return this.auth.getUser().hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS) + } + private hasConfigRight () { return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION) } diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts index c642d2a3a..d57a49f7d 100644 --- a/client/src/app/+admin/follows/following-list/follow-modal.component.ts +++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts @@ -1,15 +1,16 @@ +import { NgClass, NgIf } from '@angular/common' import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { Notifier } from '@app/core' import { formatICU } from '@app/helpers' -import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' +import { UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { NgClass, NgIf } from '@angular/common' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { splitAndGetNotEmpty } from '@root-helpers/string' import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' -import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service' @Component({ selector: 'my-follow-modal', diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts index f0494de7b..d354f8dcc 100644 --- a/client/src/app/+admin/moderation/moderation.routes.ts +++ b/client/src/app/+admin/moderation/moderation.routes.ts @@ -5,6 +5,7 @@ import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list import { UserRightGuard } from '@app/core' import { UserRight } from '@peertube/peertube-models' import { RegistrationListComponent } from './registration-list' +import { WatchedWordsListAdminComponent } from './watched-words-list/watched-words-list-admin.component' export const ModerationRoutes: Routes = [ { @@ -114,6 +115,18 @@ export const ModerationRoutes: Routes = [ title: $localize`Muted instances` } } + }, + + { + path: 'watched-words/list', + component: WatchedWordsListAdminComponent, + canActivate: [ UserRightGuard ], + data: { + userRight: UserRight.MANAGE_INSTANCE_WATCHED_WORDS, + meta: { + title: $localize`Watched words` + } + } } ] } diff --git a/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html new file mode 100644 index 000000000..230cd365f --- /dev/null +++ b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html @@ -0,0 +1,10 @@ +<h1> + <my-global-icon iconName="eye-open" aria-hidden="true"></my-global-icon> + <ng-container i18n>Instance watched words lists</ng-container> +</h1> + +<em class="d-block" i18n>Video name/description and comments that contain any of the watched words are automatically tagged with the name of the list.</em> +<em class="d-block mb-3" i18n>These automatic tags can be used to filter comments and videos.</em> + +<my-watched-words-list-admin-owner mode="admin"></my-watched-words-list-admin-owner> + diff --git a/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts new file mode 100644 index 000000000..2c15c3840 --- /dev/null +++ b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core' +import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' +import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component' + +@Component({ + templateUrl: './watched-words-list-admin.component.html', + standalone: true, + imports: [ + GlobalIconComponent, + WatchedWordsListAdminOwnerComponent + ] +}) +export class WatchedWordsListAdminComponent { } diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.html b/client/src/app/+admin/overview/comments/video-comment-list.component.html index 183854af7..0b87f0f38 100644 --- a/client/src/app/+admin/overview/comments/video-comment-list.component.html +++ b/client/src/app/+admin/overview/comments/video-comment-list.component.html @@ -7,110 +7,5 @@ <em i18n>This view also shows comments from muted accounts.</em> -<p-table - [value]="comments" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" - [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" - [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" - [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" - [expandedRowKeys]="expandedRows" [(selection)]="selectedRows" -> - <ng-template pTemplate="caption"> - <div class="caption"> - <div> - <my-action-dropdown - *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" - [actions]="bulkActions" [entry]="selectedRows" - > - </my-action-dropdown> - </div> - - <div class="ms-auto right-form"> - <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter> - - <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button> - </div> - </div> - </ng-template> - - <ng-template pTemplate="header"> - <tr> - <th scope="col" style="width: 40px;"> - <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> - </th> - <th scope="col" style="width: 40px;"> - <span i18n class="visually-hidden">More information</span> - </th> - <th scope="col" style="width: 150px;"> - <span i18n class="visually-hidden">Actions</span> - </th> - <th scope="col" style="width: 300px;" i18n>Account</th> - <th scope="col" style="width: 300px;" i18n>Video</th> - <th scope="col" i18n>Comment</th> - <th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> - </tr> - </ng-template> - - <ng-template pTemplate="body" let-videoComment let-expanded="expanded"> - <tr [pSelectableRow]="videoComment"> - - <td class="checkbox-cell"> - <p-tableCheckbox [value]="videoComment" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox> - </td> - - <td class="expand-cell"> - <my-table-expander-icon [pRowToggler]="videoComment" i18n-tooltip tooltip="See full comment" [expanded]="expanded"></my-table-expander-icon> - </td> - - <td class="action-cell"> - <my-action-dropdown - [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body" - i18n-label label="Actions" [actions]="videoCommentActions" [entry]="videoComment" - ></my-action-dropdown> - </td> - - <td> - <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> - <div class="chip two-lines"> - <my-actor-avatar [actor]="videoComment.account" actorType="account" size="32"></my-actor-avatar> - <div> - {{ videoComment.account.displayName }} - <span>{{ videoComment.by }}</span> - </div> - </div> - </a> - </td> - - <td class="video"> - <em i18n>Commented video</em> - - <a [href]="videoComment.localUrl" target="_blank" rel="noopener noreferrer">{{ videoComment.video.name }}</a> - </td> - - <td class="comment-html c-hand" [pRowToggler]="videoComment"> - <div [innerHTML]="videoComment.textHtml"></div> - </td> - - <td class="c-hand" [pRowToggler]="videoComment">{{ videoComment.createdAt | date: 'short' }}</td> - </tr> - </ng-template> - - <ng-template pTemplate="rowexpansion" let-videoComment> - <tr> - <td class="expand-cell" myAutoColspan> - <div [innerHTML]="videoComment.textHtml"></div> - </td> - </tr> - </ng-template> - - <ng-template pTemplate="emptymessage"> - <tr> - <td myAutoColspan> - <div class="no-results"> - <ng-container *ngIf="search" i18n>No comments found matching current filters.</ng-container> - <ng-container *ngIf="!search" i18n>No comments found.</ng-container> - </div> - </td> - </tr> - </ng-template> -</p-table> +<my-video-comment-list-admin-owner mode="admin"></my-video-comment-list-admin-owner> diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.scss b/client/src/app/+admin/overview/comments/video-comment-list.component.scss index 2777bf6d1..d7deffa29 100644 --- a/client/src/app/+admin/overview/comments/video-comment-list.component.scss +++ b/client/src/app/+admin/overview/comments/video-comment-list.component.scss @@ -7,54 +7,3 @@ my-feed { display: inline-block; width: 15px; } - -my-global-icon { - width: 24px; - height: 24px; -} - -.video { - display: flex; - flex-direction: column; - - em { - font-size: 11px; - } - - a { - @include ellipsis; - - color: pvar(--mainForegroundColor); - } -} - -.comment-html { - ::ng-deep { - > div { - max-height: 22px; - } - - div, - p { - @include ellipsis; - } - - p { - margin: 0; - } - } -} - -.right-form { - display: flex; - - > *:not(:last-child) { - @include margin-right(10px); - } -} - -@media screen and (max-width: $primeng-breakpoint) { - .video { - align-items: flex-start !important; - } -} diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts index f0fe8880e..5d5e7b89d 100644 --- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts +++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts @@ -1,54 +1,22 @@ -import { SortMeta, SharedModule } from 'primeng/api' -import { Component, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' -import { FeedFormat, UserRight } from '@peertube/peertube-models' -import { formatICU } from '@app/helpers' -import { AutoColspanDirective } from '../../../shared/shared-main/angular/auto-colspan.directive' -import { ActorAvatarComponent } from '../../../shared/shared-actor-image/actor-avatar.component' -import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component' -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' -import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component' -import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component' -import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component' -import { NgIf, NgClass, DatePipe } from '@angular/common' -import { TableModule } from 'primeng/table' -import { FeedComponent } from '../../../shared/shared-main/feeds/feed.component' -import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' -import { VideoCommentAdmin } from '@app/shared/shared-video-comment/video-comment.model' -import { BulkService } from '@app/shared/shared-moderation/bulk.service' +import { Component } from '@angular/core' import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' +import { FeedFormat } from '@peertube/peertube-models' +import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' +import { FeedComponent } from '../../../shared/shared-main/feeds/feed.component' +import { VideoCommentListAdminOwnerComponent } from '../../../shared/shared-video-comment/video-comment-list-admin-owner.component' @Component({ selector: 'my-video-comment-list', templateUrl: './video-comment-list.component.html', - styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ], + styleUrls: [ './video-comment-list.component.scss' ], standalone: true, imports: [ GlobalIconComponent, FeedComponent, - TableModule, - SharedModule, - NgIf, - ActionDropdownComponent, - AdvancedInputFilterComponent, - ButtonComponent, - NgbTooltip, - TableExpanderIconComponent, - NgClass, - ActorAvatarComponent, - AutoColspanDirective, - DatePipe + VideoCommentListAdminOwnerComponent ] }) -export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit { - comments: VideoCommentAdmin[] - totalRecords = 0 - sort: SortMeta = { field: 'createdAt', order: -1 } - pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - - videoCommentActions: DropdownAction<VideoCommentAdmin>[][] = [] - +export class VideoCommentListComponent { syndicationItems = [ { format: FeedFormat.RSS, @@ -66,154 +34,4 @@ export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> imp url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() } ] - - bulkActions: DropdownAction<VideoCommentAdmin[]>[] = [] - - inputFilters: AdvancedInputFilter[] = [ - { - title: $localize`Advanced filters`, - children: [ - { - value: 'local:true', - label: $localize`Local comments` - }, - { - value: 'local:false', - label: $localize`Remote comments` - }, - { - value: 'localVideo:true', - label: $localize`Comments on local videos` - } - ] - } - ] - - get authUser () { - return this.auth.getUser() - } - - constructor ( - protected router: Router, - protected route: ActivatedRoute, - private auth: AuthService, - private notifier: Notifier, - private confirmService: ConfirmService, - private videoCommentService: VideoCommentService, - private markdownRenderer: MarkdownService, - private bulkService: BulkService - ) { - super() - - this.videoCommentActions = [ - [ - { - label: $localize`Delete this comment`, - handler: comment => this.deleteComment(comment), - isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) - }, - - { - label: $localize`Delete all comments of this account`, - description: $localize`Comments are deleted after a few minutes`, - handler: comment => this.deleteUserComments(comment), - isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) - } - ] - ] - } - - ngOnInit () { - this.initialize() - - this.bulkActions = [ - { - label: $localize`Delete`, - handler: comments => this.removeComments(comments), - isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT), - iconName: 'delete' - } - ] - } - - getIdentifier () { - return 'VideoCommentListComponent' - } - - toHtml (text: string) { - return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true }) - } - - protected reloadDataInternal () { - this.videoCommentService.getAdminVideoComments({ - pagination: this.pagination, - sort: this.sort, - search: this.search - }).subscribe({ - next: async resultList => { - this.totalRecords = resultList.total - - this.comments = [] - - for (const c of resultList.data) { - this.comments.push( - new VideoCommentAdmin(c, await this.toHtml(c.text)) - ) - } - }, - - error: err => this.notifier.error(err.message) - }) - } - - private removeComments (comments: VideoCommentAdmin[]) { - const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id })) - - this.videoCommentService.deleteVideoComments(commentArgs) - .subscribe({ - next: () => { - this.notifier.success( - formatICU( - $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`, - { count: commentArgs.length } - ) - ) - - this.reloadData() - }, - - error: err => this.notifier.error(err.message), - - complete: () => this.selectedRows = [] - }) - } - - private deleteComment (comment: VideoCommentAdmin) { - this.videoCommentService.deleteVideoComment(comment.video.id, comment.id) - .subscribe({ - next: () => this.reloadData(), - - error: err => this.notifier.error(err.message) - }) - } - - private async deleteUserComments (comment: VideoCommentAdmin) { - const message = $localize`Do you really want to delete all comments of ${comment.by}?` - const res = await this.confirmService.confirm(message, $localize`Delete`) - if (res === false) return - - const options = { - accountName: comment.by, - scope: 'instance' as 'instance' - } - - this.bulkService.removeCommentsOf(options) - .subscribe({ - next: () => { - this.notifier.success($localize`Comments of ${options.accountName} will be deleted in a few minutes`) - }, - - error: err => this.notifier.error(err.message) - }) - } } diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts index 6a45eb201..8ab91be96 100644 --- a/client/src/app/+admin/overview/videos/video-admin.service.ts +++ b/client/src/app/+admin/overview/videos/video-admin.service.ts @@ -113,7 +113,8 @@ export class VideoAdminService { VideoInclude.BLOCKED_OWNER | VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.FILES | - VideoInclude.SOURCE + VideoInclude.SOURCE | + VideoInclude.AUTOMATIC_TAGS let privacyOneOf = getAllPrivacies() @@ -143,6 +144,10 @@ export class VideoAdminService { excludePublic: { prefix: 'excludePublic', handler: () => true + }, + autoTagOneOf: { + prefix: 'autoTag:', + multiple: true } }) diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html index 0bdccbab8..1dd0953f8 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.html +++ b/client/src/app/+admin/overview/videos/video-list.component.html @@ -70,22 +70,34 @@ </td> <td> - @if (video.isLocal) { - <span class="pt-badge badge-blue" i18n>Local</span> - } @else { - <span class="pt-badge badge-purple" i18n>Remote</span> - } + <div> + @if (video.isLocal) { + <span class="pt-badge badge-blue" i18n>Local</span> + } @else { + <span class="pt-badge badge-purple" i18n>Remote</span> + } - <span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span> + <span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span> - <span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span> + <span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span> - <span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span> + <span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span> - <span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span> - <span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span> + <span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span> + <span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span> - <span *ngIf="isVideoBlocked(video)" class="pt-badge badge-red" i18n>Blocked</span> + <span *ngIf="isVideoBlocked(video)" class="pt-badge badge-red" i18n>Blocked</span> + </div> + + <div> + @for (tag of video.automaticTags; track tag) { + <a + i18n-title title="Only display videos with this tag" + class="pt-badge badge-secondary me-1" + [routerLink]="[ '.' ]" [queryParams]="{ 'search': buildSearchAutoTag(tag) }" + >{{ tag }}</a> + } + </div> </td> <td> diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts index 30868499d..3e6325e32 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -1,6 +1,6 @@ import { DatePipe, NgClass, NgFor, NgIf } from '@angular/common' import { Component, OnInit, ViewChild } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' +import { ActivatedRoute, Router, RouterLink } from '@angular/router' import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { formatICU, getAbsoluteAPIUrl } from '@app/helpers' import { Video } from '@app/shared/shared-main/video/video.model' @@ -51,6 +51,7 @@ import { VideoAdminService } from './video-admin.service' EmbedComponent, VideoBlockComponent, DatePipe, + RouterLink, BytesPipe ] }) @@ -256,6 +257,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit { }) } + buildSearchAutoTag (tag: string) { + const str = `autoTag:"${tag}"` + + if (this.search) return this.search + ' ' + str + + return str + } + protected reloadDataInternal () { this.loading = true diff --git a/client/src/app/+admin/routes.ts b/client/src/app/+admin/routes.ts index a0af800c1..81e047fca 100644 --- a/client/src/app/+admin/routes.ts +++ b/client/src/app/+admin/routes.ts @@ -23,6 +23,7 @@ import { TwoFactorService } from '@app/shared/shared-users/two-factor.service' import { UserAdminService } from '@app/shared/shared-users/user-admin.service' import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' +import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service' export default [ { @@ -52,7 +53,8 @@ export default [ DynamicElementService, FindInBulkService, SearchService, - VideoPlaylistService + VideoPlaylistService, + WatchedWordsListService ], children: [ { diff --git a/client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts b/client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts new file mode 100644 index 000000000..7e435e6a9 --- /dev/null +++ b/client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts @@ -0,0 +1,45 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { AutomaticTagAvailable, CommentAutomaticTagPolicies } from '@peertube/peertube-models' +import { catchError } from 'rxjs/operators' +import { environment } from 'src/environments/environment' + +@Injectable({ providedIn: 'root' }) +export class AutomaticTagService { + private static BASE_AUTOMATIC_TAGS_URL = environment.apiUrl + '/api/v1/automatic-tags/' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) {} + + listAvailable (options: { + accountName: string + }) { + const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'accounts/' + options.accountName + '/available' + + return this.authHttp.get<AutomaticTagAvailable>(url) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + getCommentPolicies (options: { + accountName: string + }) { + const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'policies/accounts/' + options.accountName + '/comments' + + return this.authHttp.get<CommentAutomaticTagPolicies>(url) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + updateCommentPolicies (options: { + accountName: string + review: string[] + }) { + const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'policies/accounts/' + options.accountName + '/comments' + + return this.authHttp.put(url, { review: options.review }) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + +} diff --git a/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html new file mode 100644 index 000000000..8aa15f872 --- /dev/null +++ b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html @@ -0,0 +1,15 @@ +<h1> + <my-global-icon iconName="no" aria-hidden="true"></my-global-icon> + <ng-container i18n>Your automatic tag policies</ng-container> +</h1> + +<strong class="d-block mb-3" i18n>Automatically block comments:</strong> + +@for (tag of tags; track tag; let i = $index) { + <div class="form-group ms-3"> + <my-peertube-checkbox + [inputName]="'tag-' + i" [(ngModel)]="tag.review" [labelText]="getLabelText(tag)" + (ngModelChange)="updatePolicies()" + ></my-peertube-checkbox> + </div> +} diff --git a/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts new file mode 100644 index 000000000..3c8978058 --- /dev/null +++ b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { AuthService, Notifier } from '@app/core' +import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component' +import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' +import { AutomaticTagAvailableType } from '@peertube/peertube-models' +import { forkJoin } from 'rxjs' +import { first } from 'rxjs/operators' +import { AutomaticTagService } from './automatic-tag.service' + +@Component({ + templateUrl: './my-account-auto-tag-policies.component.html', + standalone: true, + imports: [ + GlobalIconComponent, + FormsModule, + PeertubeCheckboxComponent + ] +}) +export class MyAccountAutoTagPoliciesComponent implements OnInit { + tags: { name: string, review: boolean, type: AutomaticTagAvailableType }[] = [] + + constructor ( + private authService: AuthService, + private autoTagsService: AutomaticTagService, + private notifier: Notifier + ) { + + } + + ngOnInit () { + this.authService.userInformationLoaded + .pipe(first()) + .subscribe(() => this.loadAvailableTags()) + } + + getLabelText (tag: { name: string, type: AutomaticTagAvailableType }) { + if (tag.name === 'external-link') { + return $localize`That contain an external link` + } + + return $localize`That contain any word from your "${tag.name}" watched word list` + } + + updatePolicies () { + const accountName = this.authService.getUser().account.name + + this.autoTagsService.updateCommentPolicies({ + accountName, + review: this.tags.filter(t => t.review).map(t => t.name) + }).subscribe({ + next: () => { + this.notifier.success($localize`Comment policies updated`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + private loadAvailableTags () { + const accountName = this.authService.getUser().account.name + + forkJoin([ + this.autoTagsService.listAvailable({ accountName }), + this.autoTagsService.getCommentPolicies({ accountName }) + ]).subscribe(([ resAvailable, policies ]) => { + this.tags = resAvailable.available + .map(a => ({ name: a.name, type: a.type, review: policies.review.includes(a.name) })) + }) + } +} diff --git a/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html new file mode 100644 index 000000000..265767cb8 --- /dev/null +++ b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html @@ -0,0 +1,7 @@ +<h1> + <my-global-icon iconName="message-circle" aria-hidden="true"></my-global-icon> + <ng-container i18n>Comments on your videos</ng-container> +</h1> + +<my-video-comment-list-admin-owner mode="user"></my-video-comment-list-admin-owner> + diff --git a/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts new file mode 100644 index 000000000..1c3110b5e --- /dev/null +++ b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core' +import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component' +import { VideoCommentListAdminOwnerComponent } from '../../shared/shared-video-comment/video-comment-list-admin-owner.component' + +@Component({ + templateUrl: './comments-on-my-videos.component.html', + standalone: true, + imports: [ + GlobalIconComponent, + VideoCommentListAdminOwnerComponent + ] +}) +export class CommentsOnMyVideosComponent { +} diff --git a/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html new file mode 100644 index 000000000..132bf5a69 --- /dev/null +++ b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html @@ -0,0 +1,10 @@ +<h1> + <my-global-icon iconName="no" aria-hidden="true"></my-global-icon> + <ng-container i18n>Your watched words lists</ng-container> +</h1> + +<em class="d-block" i18n>Comments that contain any of the watched words are automatically tagged with the name of the list.</em> +<em class="d-block mb-3" i18n>These automatic tags can be used to filter comments or <a routerLink="/my-account/auto-tag-policies">automatically block</a> them.</em> + +<my-watched-words-list-admin-owner mode="user"></my-watched-words-list-admin-owner> + diff --git a/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts new file mode 100644 index 000000000..3f4de4f0b --- /dev/null +++ b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts @@ -0,0 +1,19 @@ +import { NgIf } from '@angular/common' +import { Component } from '@angular/core' +import { RouterLink } from '@angular/router' +import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' +import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component' + +@Component({ + templateUrl: './my-account-watched-words-list.component.html', + standalone: true, + imports: [ + GlobalIconComponent, + WatchedWordsListAdminOwnerComponent, + NgIf, + RouterLink + ] +}) +export class MyAccountWatchedWordsListComponent { + +} diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index 197956cdc..696896627 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts @@ -32,27 +32,6 @@ export class MyAccountComponent implements OnInit { private buildMenu () { const clientRoutes = this.pluginService.getAllRegisteredClientRoutesForParent('/my-account') || {} - const moderationEntries: TopMenuDropdownParam = { - label: $localize`Moderation`, - children: [ - { - label: $localize`Muted accounts`, - routerLink: '/my-account/blocklist/accounts', - iconName: 'user-x' - }, - { - label: $localize`Muted servers`, - routerLink: '/my-account/blocklist/servers', - iconName: 'peertube-x' - }, - { - label: $localize`Abuse reports`, - routerLink: '/my-account/abuses', - iconName: 'flag' - } - ] - } - this.menuEntries = [ { label: $localize`Settings`, @@ -74,7 +53,41 @@ export class MyAccountComponent implements OnInit { routerLink: '/my-account/applications' }, - moderationEntries, + { + label: $localize`Moderation`, + children: [ + { + label: $localize`Muted accounts`, + routerLink: '/my-account/blocklist/accounts', + iconName: 'user-x' + }, + { + label: $localize`Muted servers`, + routerLink: '/my-account/blocklist/servers', + iconName: 'peertube-x' + }, + { + label: $localize`Abuse reports`, + routerLink: '/my-account/abuses', + iconName: 'flag' + }, + { + label: $localize`Comments on your videos`, + routerLink: '/my-account/videos/comments', + iconName: 'message-circle' + }, + { + label: $localize`Watched words`, + routerLink: '/my-account/watched-words/list', + iconName: 'eye-open' + }, + { + label: $localize`Auto tag policies`, + routerLink: '/my-account/auto-tag-policies', + iconName: 'no' + } + ] + }, ...Object.values(clientRoutes) .map(clientRoute => ({ diff --git a/client/src/app/+my-account/routes.ts b/client/src/app/+my-account/routes.ts index d46c42c92..6c2a1e1e8 100644 --- a/client/src/app/+my-account/routes.ts +++ b/client/src/app/+my-account/routes.ts @@ -1,20 +1,25 @@ import { Routes } from '@angular/router' +import { AbuseService } from '@app/shared/shared-moderation/abuse.service' +import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service' +import { BulkService } from '@app/shared/shared-moderation/bulk.service' +import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service' +import { PluginPagesComponent } from '@app/shared/shared-plugin-pages/plugin-pages.component' +import { TwoFactorService } from '@app/shared/shared-users/two-factor.service' +import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' +import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service' import { CanDeactivateGuard, LoginGuard } from '../core' import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component' import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component' +import { MyAccountAutoTagPoliciesComponent } from './my-account-auto-tag-policies/my-account-auto-tag-policies.component' import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component' import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' +import { CommentsOnMyVideosComponent } from './my-account-comments-on-my-videos/comments-on-my-videos.component' +import { MyAccountImportExportComponent, UserImportExportService } from './my-account-import-export' import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' -import { MyAccountImportExportComponent, UserImportExportService } from './my-account-import-export' -import { MyAccountComponent } from './my-account.component' import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor/my-account-two-factor.component' -import { AbuseService } from '@app/shared/shared-moderation/abuse.service' -import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service' -import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service' -import { TwoFactorService } from '@app/shared/shared-users/two-factor.service' -import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' -import { PluginPagesComponent } from '@app/shared/shared-plugin-pages/plugin-pages.component' +import { MyAccountWatchedWordsListComponent } from './my-account-watched-words-list/my-account-watched-words-list.component' +import { MyAccountComponent } from './my-account.component' export default [ { @@ -26,7 +31,9 @@ export default [ BlocklistService, AbuseService, VideoCommentService, - VideoBlockService + VideoBlockService, + BulkService, + WatchedWordsListService ], canActivateChild: [ LoginGuard ], children: [ @@ -152,6 +159,15 @@ export default [ } } }, + { + path: 'videos/comments', + component: CommentsOnMyVideosComponent, + data: { + meta: { + title: $localize`Comments on your videos` + } + } + }, { path: 'import-export', component: MyAccountImportExportComponent, @@ -162,6 +178,24 @@ export default [ } } }, + { + path: 'watched-words/list', + component: MyAccountWatchedWordsListComponent, + data: { + meta: { + title: $localize`Your watched words` + } + } + }, + { + path: 'auto-tag-policies', + component: MyAccountAutoTagPoliciesComponent, + data: { + meta: { + title: $localize`Your automatic tag policies` + } + } + }, { path: 'p', children: [ diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index 84c165236..a1a49ff7d 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html @@ -438,10 +438,14 @@ </div> </div> - <my-peertube-checkbox - inputName="commentsEnabled" formControlName="commentsEnabled" - i18n-labelText labelText="Enable video comments" - ></my-peertube-checkbox> + <div class="form-group mb-4"> + <label i18n for="commentsPolicy">Comments policy</label> + <my-select-options labelForId="commentsPolicy" [items]="commentPolicies" formControlName="commentsPolicy" [clearable]="false"></my-select-options> + + <div *ngIf="formErrors.commentsPolicy" class="form-error" role="alert"> + {{ formErrors.commentsPolicy }} + </div> + </div> <my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 48b52e372..09d3c2b0f 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts @@ -1,10 +1,10 @@ -import { forkJoin } from 'rxjs' -import { map } from 'rxjs/operators' -import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' +import { DatePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' -import { AbstractControl, FormArray, FormGroup, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { AbstractControl, FormArray, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' import { HooksService, PluginService, ServerService } from '@app/core' import { removeElementFromArray } from '@app/helpers' +import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model' +import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' import { VIDEO_CATEGORY_VALIDATOR, VIDEO_CHANNEL_VALIDATOR, @@ -19,8 +19,14 @@ import { VIDEO_SUPPORT_VALIDATOR, VIDEO_TAGS_ARRAY_VALIDATOR } from '@app/shared/form-validators/video-validators' -import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' -import { NgbModal, NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap' +import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service' +import { InstanceService } from '@app/shared/shared-main/instance/instance.service' +import { VideoCaptionEdit, VideoCaptionWithPathEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model' +import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model' +import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model' +import { VideoService } from '@app/shared/shared-main/video/video.service' +import { NgbModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap' import { HTMLServerConfig, LiveVideo, @@ -28,6 +34,7 @@ import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions, VideoChapter, + VideoCommentPolicyType, VideoConstant, VideoDetails, VideoPrivacy, @@ -36,35 +43,29 @@ import { } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { PluginInfo } from '@root-helpers/plugins-manager' +import { CalendarModule } from 'primeng/calendar' +import { forkJoin } from 'rxjs' +import { map } from 'rxjs/operators' +import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' +import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component' +import { InputTextComponent } from '../../../shared/shared-forms/input-text.component' +import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' +import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' +import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component' +import { SelectChannelComponent } from '../../../shared/shared-forms/select/select-channel.component' +import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' +import { SelectTagsComponent } from '../../../shared/shared-forms/select/select-tags.component' +import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-input.component' +import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' +import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive' +import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component' +import { HelpComponent } from '../../../shared/shared-main/misc/help.component' +import { EmbedComponent } from '../../../shared/shared-main/video/embed.component' +import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component' import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component' import { VideoEditType } from './video-edit.type' -import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component' -import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component' -import { EmbedComponent } from '../../../shared/shared-main/video/embed.component' -import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component' -import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-input.component' -import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' -import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' -import { CalendarModule } from 'primeng/calendar' -import { InputTextComponent } from '../../../shared/shared-forms/input-text.component' -import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' -import { SelectChannelComponent } from '../../../shared/shared-forms/select/select-channel.component' -import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' -import { SelectTagsComponent } from '../../../shared/shared-forms/select/select-tags.component' -import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive' -import { HelpComponent } from '../../../shared/shared-main/misc/help.component' -import { NgIf, NgFor, NgTemplateOutlet, NgClass, DatePipe } from '@angular/common' -import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component' -import { InstanceService } from '@app/shared/shared-main/instance/instance.service' -import { VideoCaptionWithPathEdit, VideoCaptionEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model' -import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model' -import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model' -import { VideoService } from '@app/shared/shared-main/video/video.service' -import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model' -import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' -import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service' type VideoLanguages = VideoConstant<string> & { group?: string } type PluginField = { @@ -144,6 +145,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { replayPrivacies: VideoConstant<VideoPrivacyType> [] = [] videoCategories: VideoConstant<number>[] = [] videoLicences: VideoConstant<number>[] = [] + commentPolicies: VideoConstant<VideoCommentPolicyType>[] = [] videoLanguages: VideoLanguages[] = [] latencyModes: SelectOptionsItem[] = [ { @@ -202,7 +204,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { updateForm () { const defaultValues: any = { nsfw: 'false', - commentsEnabled: this.serverConfig.defaults.publish.commentsEnabled, + commentsPolicy: this.serverConfig.defaults.publish.commentsPolicy, downloadEnabled: this.serverConfig.defaults.publish.downloadEnabled, waitTranscoding: true, licence: this.serverConfig.defaults.publish.licence, @@ -214,7 +216,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { videoPassword: VIDEO_PASSWORD_VALIDATOR, channelId: VIDEO_CHANNEL_VALIDATOR, nsfw: null, - commentsEnabled: null, + commentsPolicy: null, downloadEnabled: null, waitTranscoding: null, category: VIDEO_CATEGORY_VALIDATOR, @@ -272,6 +274,9 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.serverService.getVideoLicences() .subscribe(res => this.videoLicences = res) + this.serverService.getCommentPolicies() + .subscribe(res => this.commentPolicies = res) + forkJoin([ this.instanceService.getAbout(), this.serverService.getVideoLanguages() diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html index 80ea22a20..ec0d5184e 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html @@ -27,6 +27,8 @@ <a [routerLink]="['/w', video.shortUUID, { 'threadId': comment.threadId }]" class="comment-date" [title]="comment.createdAt"> {{ comment.createdAt | myFromNow }} </a> + + <span *ngIf="comment.heldForReview" class="pt-badge badge-red ms-2" i18n>Pending review</span> </div> <div @@ -83,6 +85,7 @@ (wantedToReply)="onWantToReply($event)" (wantedToDelete)="onWantToDelete($event)" (wantedToRedraft)="onWantToRedraft($event)" + (wantedToApprove)="onWantToApprove($event)" (resetReply)="onResetReply()" (timestampClicked)="handleTimestampClicked($event)" [redraftValue]="redraftValue" diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts index 7e1ceffe7..59c48f4fe 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts @@ -1,20 +1,20 @@ +import { NgClass, NgFor, NgIf } from '@angular/common' import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core' +import { RouterLink } from '@angular/router' import { MarkdownService, Notifier, UserService } from '@app/core' import { AuthService } from '@app/core/auth' -import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component' -import { User, UserRight } from '@peertube/peertube-models' -import { FromNowPipe } from '../../../../shared/shared-main/angular/from-now.pipe' -import { VideoCommentAddComponent } from './video-comment-add.component' -import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component' -import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive' -import { RouterLink } from '@angular/router' -import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component' -import { NgIf, NgClass, NgFor } from '@angular/common' -import { Video } from '@app/shared/shared-main/video/video.model' import { Account } from '@app/shared/shared-main/account/account.model' import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component' -import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model' +import { Video } from '@app/shared/shared-main/video/video.model' +import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component' import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model' +import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model' +import { User, UserRight } from '@peertube/peertube-models' +import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component' +import { FromNowPipe } from '../../../../shared/shared-main/angular/from-now.pipe' +import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component' +import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive' +import { VideoCommentAddComponent } from './video-comment-add.component' @Component({ selector: 'my-video-comment', @@ -49,6 +49,7 @@ export class VideoCommentComponent implements OnInit, OnChanges { @Output() wantedToReply = new EventEmitter<VideoComment>() @Output() wantedToDelete = new EventEmitter<VideoComment>() + @Output() wantedToApprove = new EventEmitter<VideoComment>() @Output() wantedToRedraft = new EventEmitter<VideoComment>() @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>() @Output() resetReply = new EventEmitter() @@ -115,6 +116,10 @@ export class VideoCommentComponent implements OnInit, OnChanges { this.wantedToRedraft.emit(comment || this.comment) } + onWantToApprove (comment?: VideoComment) { + this.wantedToApprove.emit(comment || this.comment) + } + isUserLoggedIn () { return this.authService.isLoggedIn() } @@ -127,12 +132,12 @@ export class VideoCommentComponent implements OnInit, OnChanges { this.timestampClicked.emit(timestamp) } - isRemovableByUser () { + canBeRemovedOrApprovedByUser () { return this.comment.account && this.isUserLoggedIn() && ( this.user.account.id === this.comment.account.id || this.user.account.id === this.video.account.id || - this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) + this.user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) ) } @@ -196,6 +201,14 @@ export class VideoCommentComponent implements OnInit, OnChanges { this.prependModerationActions = [] + if (this.canBeRemovedOrApprovedByUser() && this.comment.heldForReview) { + this.prependModerationActions.push({ + label: $localize`Approve`, + iconName: 'tick', + handler: () => this.onWantToApprove() + }) + } + if (this.isReportableByUser()) { this.prependModerationActions.push({ label: $localize`Report this comment`, @@ -204,7 +217,7 @@ export class VideoCommentComponent implements OnInit, OnChanges { }) } - if (this.isRemovableByUser()) { + if (this.canBeRemovedOrApprovedByUser()) { this.prependModerationActions.push({ label: $localize`Remove`, iconName: 'delete', diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html index 0932d2b7f..b2cbd01f6 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html @@ -17,7 +17,7 @@ </div> </div> - <ng-template [ngIf]="video.commentsEnabled === true"> + @if (commentsEnabled) { <my-video-comment-add [video]="video" [videoPassword]="videoPassword" @@ -43,6 +43,7 @@ (wantedToReply)="onWantedToReply($event)" (wantedToDelete)="onWantedToDelete($event)" (wantedToRedraft)="onWantedToRedraft($event)" + (wantedToApprove)="onWantToApprove($event)" (threadCreated)="onThreadCreated($event)" (resetReply)="onResetReply()" (timestampClicked)="handleTimestampClicked($event)" @@ -62,6 +63,7 @@ (wantedToReply)="onWantedToReply($event)" (wantedToDelete)="onWantedToDelete($event)" (wantedToRedraft)="onWantedToRedraft($event)" + (wantedToApprove)="onWantToApprove($event)" (threadCreated)="onThreadCreated($event)" (resetReply)="onResetReply()" (timestampClicked)="handleTimestampClicked($event)" @@ -89,9 +91,7 @@ </div> </div> - </ng-template> - - <div *ngIf="video.commentsEnabled === false" i18n> - Comments are disabled. - </div> + } @else { + <div i18n>Comments are disabled.</div> + } </div> diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts index 32a90f98d..e88b1699c 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts @@ -1,21 +1,21 @@ -import { Subject, Subscription } from 'rxjs' +import { NgFor, NgIf } from '@angular/common' import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' +import { AuthService, ComponentPagination, ConfirmService, Notifier, User, hasMoreItems } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' -import { PeerTubeProblemDocument, ServerErrorCode } from '@peertube/peertube-models' -import { LoaderComponent } from '../../../../shared/shared-main/loaders/loader.component' -import { VideoCommentComponent } from './video-comment.component' -import { InfiniteScrollerDirective } from '../../../../shared/shared-main/angular/infinite-scroller.directive' -import { VideoCommentAddComponent } from './video-comment-add.component' -import { NgIf, NgFor } from '@angular/common' -import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownButtonItem, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap' -import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component' -import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' import { Syndication } from '@app/shared/shared-main/feeds/syndication.model' +import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' +import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model' import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model' import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' -import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model' +import { NgbDropdown, NgbDropdownButtonItem, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from '@ng-bootstrap/ng-bootstrap' +import { PeerTubeProblemDocument, ServerErrorCode, VideoCommentPolicy } from '@peertube/peertube-models' +import { Subject, Subscription } from 'rxjs' +import { InfiniteScrollerDirective } from '../../../../shared/shared-main/angular/infinite-scroller.directive' +import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component' +import { LoaderComponent } from '../../../../shared/shared-main/loaders/loader.component' +import { VideoCommentAddComponent } from './video-comment-add.component' +import { VideoCommentComponent } from './video-comment.component' @Component({ selector: 'my-video-comments', @@ -61,6 +61,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { commentReplyRedraftValue: string commentThreadRedraftValue: string + commentsEnabled: boolean + threadComments: { [ id: number ]: VideoCommentThreadTree } = {} threadLoading: { [ id: number ]: boolean } = {} @@ -258,6 +260,19 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { } } + onWantToApprove (comment: VideoComment) { + this.videoCommentService.approveComments([ { commentId: comment.id, videoId: comment.videoId } ]) + .subscribe({ + next: () => { + comment.heldForReview = false + + this.notifier.success($localize`Comment approved`) + }, + + error: err => this.notifier.error(err.message) + }) + } + isUserLoggedIn () { return this.authService.isLoggedIn() } @@ -277,23 +292,25 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { } private resetVideo () { - if (this.video.commentsEnabled === true) { - // Reset all our fields - this.highlightedThread = null - this.comments = [] - this.threadComments = {} - this.threadLoading = {} - this.inReplyToCommentId = undefined - this.componentPagination.currentPage = 1 - this.componentPagination.totalItems = null - this.totalNotDeletedComments = null + if (this.video.commentsPolicy.id === VideoCommentPolicy.DISABLED) return - this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video) - this.loadMoreThreads() + // Reset all our fields + this.highlightedThread = null + this.comments = [] + this.threadComments = {} + this.threadLoading = {} + this.inReplyToCommentId = undefined + this.componentPagination.currentPage = 1 + this.componentPagination.totalItems = null + this.totalNotDeletedComments = null - if (this.activatedRoute.snapshot.params['threadId']) { - this.processHighlightedThread(+this.activatedRoute.snapshot.params['threadId']) - } + this.commentsEnabled = true + + this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video) + this.loadMoreThreads() + + if (this.activatedRoute.snapshot.params['threadId']) { + this.processHighlightedThread(+this.activatedRoute.snapshot.params['threadId']) } } diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 75ac8ddc1..f60b50d3b 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts @@ -8,6 +8,7 @@ import { HTMLServerConfig, ServerConfig, ServerStats, + VideoCommentPolicy, VideoConstant, VideoPlaylistPrivacyType, VideoPrivacyType @@ -104,6 +105,24 @@ export class ServerService { return this.htmlConfig } + getCommentPolicies () { + return of([ + { + id: VideoCommentPolicy.DISABLED, + label: $localize`Comments are disabled` + }, + { + id: VideoCommentPolicy.ENABLED, + label: $localize`Comments are enabled`, + description: $localize`Comments may require approval depending on your auto tag policies` + }, + { + id: VideoCommentPolicy.REQUIRES_APPROVAL, + label: $localize`Any new comment requires approval` + } + ]) + } + getVideoCategories () { if (!this.videoCategoriesObservable) { this.videoCategoriesObservable = this.loadAttributeEnum<number>(ServerService.BASE_VIDEO_URL, 'categories', true) diff --git a/client/src/app/shared/form-validators/host-validators.ts b/client/src/app/shared/form-validators/host-validators.ts index 3d9c476b5..602ea80ec 100644 --- a/client/src/app/shared/form-validators/host-validators.ts +++ b/client/src/app/shared/form-validators/host-validators.ts @@ -1,5 +1,7 @@ import { AbstractControl, ValidatorFn, Validators } from '@angular/forms' +import { splitAndGetNotEmpty } from '@root-helpers/string' import { BuildFormValidator } from './form-validator.model' +import { unique } from './shared/validator-utils' export function validateHost (value: string) { // Thanks to http://stackoverflow.com/a/106223 @@ -64,28 +66,6 @@ const validHostsOrHandles: ValidatorFn = (control: AbstractControl) => { // --------------------------------------------------------------------------- -export function splitAndGetNotEmpty (value: string) { - return value - .split('\n') - .filter(line => line && line.length !== 0) // Eject empty hosts -} - -export const unique: ValidatorFn = (control: AbstractControl) => { - if (!control.value) return null - - const hosts = splitAndGetNotEmpty(control.value) - - if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { - return null - } - - return { - unique: { - reason: 'invalid' - } - } -} - export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = { VALIDATORS: [ Validators.required, validHosts, unique ], MESSAGES: { diff --git a/client/src/app/shared/form-validators/shared/validator-utils.ts b/client/src/app/shared/form-validators/shared/validator-utils.ts new file mode 100644 index 000000000..6e0e411a2 --- /dev/null +++ b/client/src/app/shared/form-validators/shared/validator-utils.ts @@ -0,0 +1,18 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms' +import { splitAndGetNotEmpty } from '@root-helpers/string' + +export const unique: ValidatorFn = (control: AbstractControl) => { + if (!control.value) return null + + const hosts = splitAndGetNotEmpty(control.value) + + if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { + return null + } + + return { + unique: { + reason: 'invalid' + } + } +} diff --git a/client/src/app/shared/form-validators/watched-words-list-validators.ts b/client/src/app/shared/form-validators/watched-words-list-validators.ts new file mode 100644 index 000000000..9a932aa6a --- /dev/null +++ b/client/src/app/shared/form-validators/watched-words-list-validators.ts @@ -0,0 +1,51 @@ +import { AbstractControl, ValidatorFn, Validators } from '@angular/forms' +import { splitAndGetNotEmpty } from '@root-helpers/string' +import { BuildFormValidator } from './form-validator.model' +import { unique } from './shared/validator-utils' + +const validWords: ValidatorFn = (control: AbstractControl) => { + if (!control.value) return null + + const errors = [] + const words = splitAndGetNotEmpty(control.value) + + for (const word of words) { + if (word.length < 1 || word.length > 100) { + errors.push($localize`${word} is not valid (min 1 character/max 100 characters)`) + } + } + + if (words.length > 500) { + errors.push($localize`There are too much words in the list (max 500 words)`) + } + + // valid + if (errors.length === 0) return null + + return { + validWords: { + reason: 'invalid', + value: errors.join('. ') + '.' + } + } +} + +// --------------------------------------------------------------------------- + +export const WATCHED_WORDS_LIST_NAME_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(100) ], + MESSAGES: { + required: $localize`List name is required.`, + minlength: $localize`List name must be at least 1 character long.`, + maxlength: $localize`List name cannot be more than 100 characters long.` + } +} + +export const UNIQUE_WATCHED_WORDS_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ Validators.required, unique, validWords ], + MESSAGES: { + required: $localize`Words are required.`, + unique: $localize`Words entered contain duplicates.`, + validWords: $localize`A word must be between 1 and 100 characters and the total number of words must not exceed 500 items` + } +} diff --git a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html index 9d8a5ba68..00c95fec2 100644 --- a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html +++ b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html @@ -10,35 +10,37 @@ <ng-container *ngIf="!menuEntry.routerLink && isDisplayed(menuEntry)"> <!-- On mobile, use a modal to display sub menu items --> - <li *ngIf="isInSmallView"> - <button class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" (click)="openModal(id)"> - {{ menuEntry.label }} - - <span class="chevron-down"></span> - </button> - </li> - - <!-- On desktop, use a classic dropdown --> - <div *ngIf="!isInSmallView" ngbDropdown #dropdown="ngbDropdown" autoClose="true" container="body"> + @if (isInSmallView) { <li> - <button ngbDropdownToggle class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }">{{ menuEntry.label }}</button> + <button class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" (click)="openModal(id)"> + {{ menuEntry.label }} + + <span class="chevron-down"></span> + </button> </li> - - <ul ngbDropdownMenu> - <li *ngFor="let menuChild of menuEntry.children"> - <a - *ngIf="isDisplayed(menuChild)" ngbDropdownItem - routerLinkActive="active" ariaCurrentWhenActive="page" - [routerLink]="menuChild.routerLink" #routerLink (click)="onActiveLinkScrollToTop(routerLink)" - [queryParams]="menuChild.queryParams" - > - <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon> - - {{ menuChild.label }} - </a> + } @else { + <!-- On desktop, use a classic dropdown --> + <div ngbDropdown #dropdown="ngbDropdown" autoClose="true" container="body"> + <li> + <button ngbDropdownToggle class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }">{{ menuEntry.label }}</button> </li> - </ul> - </div> + + <ul ngbDropdownMenu> + <li *ngFor="let menuChild of menuEntry.children"> + <a + *ngIf="isDisplayed(menuChild)" ngbDropdownItem + routerLinkActive="active" ariaCurrentWhenActive="page" + [routerLink]="menuChild.routerLink" #routerLink (click)="onActiveLinkScrollToTop(routerLink)" + [queryParams]="menuChild.queryParams" + > + <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon> + + {{ menuChild.label }} + </a> + </li> + </ul> + </div> + } </ng-container> </ul> </div> diff --git a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts index eab69c58d..6370daa7c 100644 --- a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts +++ b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts @@ -1,6 +1,6 @@ import { Subscription } from 'rxjs' import { filter } from 'rxjs/operators' -import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { Component, Input, OnChanges, OnDestroy, OnInit, ViewChild } from '@angular/core' import { NavigationEnd, Router, RouterLinkActive, RouterLink } from '@angular/router' import { MenuService, ScreenService } from '@app/core' import { scrollToTop } from '@app/helpers' @@ -42,7 +42,7 @@ export type TopMenuDropdownParam = { GlobalIconComponent ] }) -export class TopMenuDropdownComponent implements OnInit, OnDestroy { +export class TopMenuDropdownComponent implements OnInit, OnChanges, OnDestroy { @Input() menuEntries: TopMenuDropdownParam[] = [] @ViewChild('modal', { static: true }) modal: NgbModal @@ -82,6 +82,10 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy { .subscribe(() => this.updateChildLabels(window.location.pathname)) } + ngOnChanges () { + this.updateChildLabels(window.location.pathname) + } + ngOnDestroy () { if (this.routeSub) this.routeSub.unsubscribe() } diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts index 1a58a6218..8e5907255 100644 --- a/client/src/app/shared/shared-main/users/user-notification.model.ts +++ b/client/src/app/shared/shared-main/users/user-notification.model.ts @@ -36,6 +36,7 @@ export class UserNotification implements UserNotificationServer { comment?: { id: number threadId: number + heldForReview: boolean account: ActorInfo & { avatarUrl?: string } video: VideoInfo } @@ -96,6 +97,9 @@ export class UserNotification implements UserNotificationServer { videoUrl?: string commentUrl?: any[] + commentReviewUrl?: string + commentReviewQueryParams?: { [id: string]: string } = {} + abuseUrl?: string abuseQueryParams?: { [id: string]: string } = {} @@ -163,6 +167,9 @@ export class UserNotification implements UserNotificationServer { if (!this.comment) break this.accountUrl = this.buildAccountUrl(this.comment.account) this.commentUrl = this.buildCommentUrl(this.comment) + + this.commentReviewUrl = '/my-account/videos/comments' + this.commentReviewQueryParams.search = 'heldForReview:true' break case UserNotificationType.NEW_ABUSE_FOR_MODERATORS: diff --git a/client/src/app/shared/shared-main/video/video-details.model.ts b/client/src/app/shared/shared-main/video/video-details.model.ts index 5ad0bb857..f595414c0 100644 --- a/client/src/app/shared/shared-main/video/video-details.model.ts +++ b/client/src/app/shared/shared-main/video/video-details.model.ts @@ -1,6 +1,7 @@ import { Account } from '@app/shared/shared-main/account/account.model' import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model' import { + VideoCommentPolicyType, VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, @@ -16,12 +17,14 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { channel: VideoChannel tags: string[] account: Account - commentsEnabled: boolean downloadEnabled: boolean waitTranscoding: boolean state: VideoConstant<VideoStateType> + commentsEnabled: never + commentsPolicy: VideoConstant<VideoCommentPolicyType> + likesPercent: number dislikesPercent: number @@ -40,7 +43,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { this.account = new Account(hash.account) this.tags = hash.tags this.support = hash.support - this.commentsEnabled = hash.commentsEnabled + this.commentsPolicy = hash.commentsPolicy this.downloadEnabled = hash.downloadEnabled this.inputFileUpdatedAt = hash.inputFileUpdatedAt diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts index a3e736c0f..e8e1f90ca 100644 --- a/client/src/app/shared/shared-main/video/video-edit.model.ts +++ b/client/src/app/shared/shared-main/video/video-edit.model.ts @@ -1,6 +1,13 @@ import { getAbsoluteAPIUrl } from '@app/helpers' import { objectKeysTyped } from '@peertube/peertube-core-utils' -import { VideoPassword, VideoPrivacy, VideoPrivacyType, VideoScheduleUpdate, VideoUpdate } from '@peertube/peertube-models' +import { + VideoCommentPolicyType, + VideoPassword, + VideoPrivacy, + VideoPrivacyType, + VideoScheduleUpdate, + VideoUpdate +} from '@peertube/peertube-models' import { VideoDetails } from './video-details.model' export class VideoEdit implements VideoUpdate { @@ -13,7 +20,7 @@ export class VideoEdit implements VideoUpdate { name: string tags: string[] nsfw: boolean - commentsEnabled: boolean + commentsPolicy: VideoCommentPolicyType downloadEnabled: boolean waitTranscoding: boolean channelId: number @@ -52,7 +59,7 @@ export class VideoEdit implements VideoUpdate { this.support = video.support - this.commentsEnabled = video.commentsEnabled + this.commentsPolicy = video.commentsPolicy.id this.downloadEnabled = video.downloadEnabled if (video.thumbnailPath) this.thumbnailUrl = getAbsoluteAPIUrl() + video.thumbnailPath @@ -109,7 +116,7 @@ export class VideoEdit implements VideoUpdate { name: this.name, tags: this.tags, nsfw: this.nsfw, - commentsEnabled: this.commentsEnabled, + commentsPolicy: this.commentsPolicy, downloadEnabled: this.downloadEnabled, waitTranscoding: this.waitTranscoding, channelId: this.channelId, diff --git a/client/src/app/shared/shared-main/video/video-import.service.ts b/client/src/app/shared/shared-main/video/video-import.service.ts index 7aa330bc4..05101d72d 100644 --- a/client/src/app/shared/shared-main/video/video-import.service.ts +++ b/client/src/app/shared/shared-main/video/video-import.service.ts @@ -99,7 +99,7 @@ export class VideoImportService { tags: video.tags, nsfw: video.nsfw, waitTranscoding: video.waitTranscoding, - commentsEnabled: video.commentsEnabled, + commentsPolicy: video.commentsPolicy, downloadEnabled: video.downloadEnabled, thumbnailfile: video.thumbnailfile, previewfile: video.previewfile, diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 7c5b2e9ca..5bbe5431f 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -114,6 +114,8 @@ export class Video implements VideoServerModel { videoSource?: VideoSource + automaticTags?: string[] + static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) { return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid }) } @@ -205,6 +207,8 @@ export class Video implements VideoServerModel { this.pluginData = hash.pluginData this.aspectRatio = hash.aspectRatio + + this.automaticTags = hash.automaticTags } isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) { diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 33acad2cb..c287b921b 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -109,7 +109,7 @@ export class VideoService { tags: video.tags, nsfw: video.nsfw, waitTranscoding: video.waitTranscoding, - commentsEnabled: video.commentsEnabled, + commentsPolicy: video.commentsPolicy, downloadEnabled: video.downloadEnabled, thumbnailfile: video.thumbnailfile, previewfile: video.previewfile, diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts index 9d33a12c1..f524193fa 100644 --- a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts +++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts @@ -1,11 +1,12 @@ +import { NgClass, NgIf } from '@angular/common' import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators' -import { NgClass, NgIf } from '@angular/common' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { splitAndGetNotEmpty } from '@root-helpers/string' +import { UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators' import { GlobalIconComponent } from '../shared-icons/global-icon.component' @Component({ diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts index e982db51f..e6fd5cf06 100644 --- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts +++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts @@ -407,7 +407,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges { ]) } - if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) { + if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) { instanceActions = instanceActions.concat([ { label: $localize`Remove comments from your instance`, diff --git a/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html new file mode 100644 index 000000000..a9fd8367a --- /dev/null +++ b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html @@ -0,0 +1,122 @@ +<p-table + [value]="comments" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" + [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" + [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" + [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" + [expandedRowKeys]="expandedRows" [(selection)]="selectedRows" +> + <ng-template pTemplate="caption"> + <div class="caption"> + <div> + <my-action-dropdown + *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" + [actions]="bulkActions" [entry]="selectedRows" + > + </my-action-dropdown> + </div> + + <div class="ms-auto right-form"> + <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter> + + <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button> + </div> + </div> + </ng-template> + + <ng-template pTemplate="header"> + <tr> + <th scope="col" style="width: 40px;"> + <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> + </th> + <th scope="col" style="width: 40px;"> + <span i18n class="visually-hidden">More information</span> + </th> + <th scope="col" style="width: 150px;"> + <span i18n class="visually-hidden">Actions</span> + </th> + <th scope="col" i18n>Account</th> + <th scope="col" i18n>Video</th> + <th scope="col" i18n>Comment</th> + <th scope="col" i18n>Auto tags</th> + <th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> + </tr> + </ng-template> + + <ng-template pTemplate="body" let-videoComment let-expanded="expanded"> + <tr [pSelectableRow]="videoComment"> + + <td class="checkbox-cell"> + <p-tableCheckbox [value]="videoComment" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox> + </td> + + <td class="expand-cell"> + <my-table-expander-icon [pRowToggler]="videoComment" i18n-tooltip tooltip="See full comment" [expanded]="expanded"></my-table-expander-icon> + </td> + + <td class="action-cell"> + <my-action-dropdown + [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body" + i18n-label label="Actions" [actions]="videoCommentActions" [entry]="videoComment" + ></my-action-dropdown> + </td> + + <td> + <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> + <div class="chip two-lines"> + <my-actor-avatar [actor]="videoComment.account" actorType="account" size="32"></my-actor-avatar> + <div> + {{ videoComment.account.displayName }} + <span>{{ videoComment.by }}</span> + </div> + </div> + </a> + </td> + + <td class="video"> + <em i18n>Commented video</em> + + <a [href]="videoComment.localUrl" target="_blank" rel="noopener noreferrer">{{ videoComment.video.name }}</a> + </td> + + <td class="c-hand comment-content-cell" [pRowToggler]="videoComment"> + <span *ngIf="videoComment.heldForReview" class="pt-badge badge-red float-start me-2" i18n>Pending review</span> + + <div class="comment-html"> + <div [innerHTML]="videoComment.textHtml"></div> + </div> + </td> + + <td> + @for (tag of videoComment.automaticTags; track tag) { + <a + i18n-title title="Only display comments with this tag" + class="pt-badge badge-secondary me-1" + [routerLink]="[ '.' ]" [queryParams]="{ 'search': buildSearchAutoTag(tag) }" + >{{ tag }}</a> + } + </td> + + <td class="c-hand" [pRowToggler]="videoComment">{{ videoComment.createdAt | date: 'short' }}</td> + </tr> + </ng-template> + + <ng-template pTemplate="rowexpansion" let-videoComment> + <tr> + <td class="expand-cell" myAutoColspan> + <div [innerHTML]="videoComment.textHtml"></div> + </td> + </tr> + </ng-template> + + <ng-template pTemplate="emptymessage"> + <tr> + <td myAutoColspan> + <div class="no-results"> + <ng-container *ngIf="search" i18n>No comments found matching current filters.</ng-container> + <ng-container *ngIf="!search" i18n>No comments found.</ng-container> + </div> + </td> + </tr> + </ng-template> +</p-table> + diff --git a/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss new file mode 100644 index 000000000..875ed2602 --- /dev/null +++ b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss @@ -0,0 +1,60 @@ +@use '_variables' as *; +@use '_mixins' as *; + +my-global-icon { + width: 24px; + height: 24px; +} + +.video { + display: flex; + flex-direction: column; + + em { + font-size: 11px; + } + + a { + @include ellipsis; + + color: pvar(--mainForegroundColor); + } +} + +.comment-content-cell { + > .pt-badge { + position: relative; + top: 2px; + } +} + +.comment-html { + ::ng-deep { + > div { + max-height: 22px; + } + + div, + p { + @include ellipsis; + } + + p { + margin: 0; + } + } +} + +.right-form { + display: flex; + + > *:not(:last-child) { + @include margin-right(10px); + } +} + +@media screen and (max-width: $primeng-breakpoint) { + .video { + align-items: flex-start !important; + } +} diff --git a/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts new file mode 100644 index 000000000..0d3c24e1a --- /dev/null +++ b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts @@ -0,0 +1,260 @@ +import { DatePipe, NgClass, NgIf } from '@angular/common' +import { Component, Input, OnInit } from '@angular/core' +import { ActivatedRoute, Router, RouterLink } from '@angular/router' +import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' +import { formatICU } from '@app/helpers' +import { BulkService } from '@app/shared/shared-moderation/bulk.service' +import { VideoCommentForAdminOrUser } from '@app/shared/shared-video-comment/video-comment.model' +import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' +import { UserRight } from '@peertube/peertube-models' +import { SharedModule, SortMeta } from 'primeng/api' +import { TableModule } from 'primeng/table' +import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component' +import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../shared-forms/advanced-input-filter.component' +import { GlobalIconComponent } from '../shared-icons/global-icon.component' +import { AutoColspanDirective } from '../shared-main/angular/auto-colspan.directive' +import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component' +import { ButtonComponent } from '../shared-main/buttons/button.component' +import { FeedComponent } from '../shared-main/feeds/feed.component' +import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon.component' + +@Component({ + selector: 'my-video-comment-list-admin-owner', + templateUrl: './video-comment-list-admin-owner.component.html', + styleUrls: [ '../shared-moderation/moderation.scss', './video-comment-list-admin-owner.component.scss' ], + standalone: true, + imports: [ + GlobalIconComponent, + FeedComponent, + TableModule, + SharedModule, + NgIf, + ActionDropdownComponent, + AdvancedInputFilterComponent, + ButtonComponent, + NgbTooltip, + TableExpanderIconComponent, + NgClass, + ActorAvatarComponent, + AutoColspanDirective, + DatePipe, + RouterLink + ] +}) +export class VideoCommentListAdminOwnerComponent extends RestTable <VideoCommentForAdminOrUser> implements OnInit { + @Input({ required: true }) mode: 'user' | 'admin' + + comments: VideoCommentForAdminOrUser[] + totalRecords = 0 + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + videoCommentActions: DropdownAction<VideoCommentForAdminOrUser>[][] = [] + + bulkActions: DropdownAction<VideoCommentForAdminOrUser[]>[] = [] + + inputFilters: AdvancedInputFilter[] = [] + + get authUser () { + return this.auth.getUser() + } + + constructor ( + protected router: Router, + protected route: ActivatedRoute, + private auth: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private videoCommentService: VideoCommentService, + private markdownRenderer: MarkdownService, + private bulkService: BulkService + ) { + super() + + this.videoCommentActions = [ + [ + { + label: $localize`Delete this comment`, + handler: comment => this.removeComment(comment), + isDisplayed: () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) + }, + { + label: $localize`Delete all comments of this account`, + description: $localize`Comments are deleted after a few minutes`, + handler: comment => this.removeCommentsOfAccount(comment), + isDisplayed: () => this.mode === 'admin' && this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) + } + ], + [ + { + label: $localize`Approve this comment`, + handler: comment => this.approveComments([ comment ]), + isDisplayed: comment => this.mode === 'user' && comment.heldForReview + } + ] + ] + } + + ngOnInit () { + this.initialize() + + this.bulkActions = [ + { + label: $localize`Delete`, + handler: comments => this.removeComments(comments), + isDisplayed: () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT), + iconName: 'delete' + }, + { + label: $localize`Approve`, + handler: comments => this.approveComments(comments), + isDisplayed: comments => this.mode === 'user' && comments.every(c => c.heldForReview), + iconName: 'tick' + } + ] + + if (this.mode === 'admin') { + this.inputFilters = [ + { + title: $localize`Advanced filters`, + children: [ + { + value: 'local:true', + label: $localize`Local comments` + }, + { + value: 'local:false', + label: $localize`Remote comments` + }, + { + value: 'localVideo:true', + label: $localize`Comments on local videos` + } + ] + } + ] + } else { + this.inputFilters = [ + { + title: $localize`Advanced filters`, + children: [ + { + value: 'heldForReview:true', + label: $localize`Display comments awaiting your approval` + } + ] + } + ] + } + } + + getIdentifier () { + return 'VideoCommentListAdminOwnerComponent' + } + + toHtml (text: string) { + return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true }) + } + + buildSearchAutoTag (tag: string) { + const str = `autoTag:"${tag}"` + + if (this.search) return this.search + ' ' + str + + return str + } + + protected reloadDataInternal () { + const method = this.mode === 'admin' + ? this.videoCommentService.listAdminVideoComments.bind(this.videoCommentService) + : this.videoCommentService.listVideoCommentsOfMyVideos.bind(this.videoCommentService) + + method({ pagination: this.pagination, sort: this.sort, search: this.search }).subscribe({ + next: async resultList => { + this.totalRecords = resultList.total + + this.comments = [] + + for (const c of resultList.data) { + this.comments.push(new VideoCommentForAdminOrUser(c, await this.toHtml(c.text))) + } + }, + + error: err => this.notifier.error(err.message) + }) + } + + private approveComments (comments: VideoCommentForAdminOrUser[]) { + const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id })) + + this.videoCommentService.approveComments(commentArgs) + .subscribe({ + next: () => { + this.notifier.success( + formatICU( + $localize`{count, plural, =1 {Comment approved.} other {{count} comments approved.}}`, + { count: commentArgs.length } + ) + ) + + this.reloadData() + }, + + error: err => this.notifier.error(err.message), + + complete: () => this.selectedRows = [] + }) + } + + private removeComments (comments: VideoCommentForAdminOrUser[]) { + const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id })) + + this.videoCommentService.deleteVideoComments(commentArgs) + .subscribe({ + next: () => { + this.notifier.success( + formatICU( + $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`, + { count: commentArgs.length } + ) + ) + + this.reloadData() + }, + + error: err => this.notifier.error(err.message), + + complete: () => this.selectedRows = [] + }) + } + + private removeComment (comment: VideoCommentForAdminOrUser) { + this.videoCommentService.deleteVideoComment(comment.video.id, comment.id) + .subscribe({ + next: () => this.reloadData(), + + error: err => this.notifier.error(err.message) + }) + } + + private async removeCommentsOfAccount (comment: VideoCommentForAdminOrUser) { + const message = $localize`Do you really want to delete all comments of ${comment.by}?` + const res = await this.confirmService.confirm(message, $localize`Delete`) + if (res === false) return + + const options = { + accountName: comment.by, + scope: 'instance' as 'instance' + } + + this.bulkService.removeCommentsOf(options) + .subscribe({ + next: () => { + this.notifier.success($localize`Comments of ${options.accountName} will be deleted in a few minutes`) + }, + + error: err => this.notifier.error(err.message) + }) + } +} diff --git a/client/src/app/shared/shared-video-comment/video-comment.model.ts b/client/src/app/shared/shared-video-comment/video-comment.model.ts index 7b02f29cc..efa9228b3 100644 --- a/client/src/app/shared/shared-video-comment/video-comment.model.ts +++ b/client/src/app/shared/shared-video-comment/video-comment.model.ts @@ -2,7 +2,7 @@ import { getAbsoluteAPIUrl } from '@app/helpers' import { Account as AccountInterface, VideoComment as VideoCommentServerModel, - VideoCommentAdmin as VideoCommentAdminServerModel + VideoCommentForAdminOrUser as VideoCommentForAdminOrUserServerModel } from '@peertube/peertube-models' import { Actor } from '../shared-main/account/actor.model' import { Video } from '../shared-main/video/video.model' @@ -18,6 +18,7 @@ export class VideoComment implements VideoCommentServerModel { updatedAt: Date | string deletedAt: Date | string isDeleted: boolean + heldForReview: boolean account: AccountInterface totalRepliesFromVideoAuthor: number totalReplies: number @@ -36,6 +37,7 @@ export class VideoComment implements VideoCommentServerModel { this.updatedAt = new Date(hash.updatedAt.toString()) this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null this.isDeleted = hash.isDeleted + this.heldForReview = hash.heldForReview this.account = hash.account this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor this.totalReplies = hash.totalReplies @@ -50,7 +52,7 @@ export class VideoComment implements VideoCommentServerModel { } } -export class VideoCommentAdmin implements VideoCommentAdminServerModel { +export class VideoCommentForAdminOrUser implements VideoCommentForAdminOrUserServerModel { id: number url: string text: string @@ -72,20 +74,28 @@ export class VideoCommentAdmin implements VideoCommentAdminServerModel { localUrl: string } + heldForReview: boolean + + automaticTags: string[] + by: string - constructor (hash: VideoCommentAdminServerModel, textHtml: string) { + constructor (hash: VideoCommentForAdminOrUserServerModel, textHtml: string) { this.id = hash.id this.url = hash.url this.text = hash.text this.textHtml = textHtml + this.heldForReview = hash.heldForReview + this.threadId = hash.threadId this.inReplyToCommentId = hash.inReplyToCommentId this.createdAt = new Date(hash.createdAt.toString()) this.updatedAt = new Date(hash.updatedAt.toString()) + this.automaticTags = hash.automaticTags + this.video = { id: hash.video.id, uuid: hash.video.uuid, diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts index 97c735d71..73faa0b97 100644 --- a/client/src/app/shared/shared-video-comment/video-comment.service.ts +++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts @@ -1,6 +1,3 @@ -import { SortMeta } from 'primeng/api' -import { from, Observable } from 'rxjs' -import { catchError, concatMap, map, toArray } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' @@ -10,21 +7,25 @@ import { ResultList, ThreadsResultList, Video, - VideoComment as VideoCommentServerModel, - VideoCommentAdmin, VideoCommentCreate, + VideoCommentForAdminOrUser, + VideoComment as VideoCommentServerModel, VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@peertube/peertube-models' +import { SortMeta } from 'primeng/api' +import { Observable, from } from 'rxjs' +import { catchError, concatMap, map, toArray } from 'rxjs/operators' import { environment } from '../../../environments/environment' +import { VideoPasswordService } from '../shared-main/video/video-password.service' import { VideoCommentThreadTree } from './video-comment-thread-tree.model' import { VideoComment } from './video-comment.model' -import { VideoPasswordService } from '../shared-main/video/video-password.service' @Injectable() export class VideoCommentService { static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' + private static BASE_ME_URL = environment.apiUrl + '/api/v1/users/me/' constructor ( private authHttp: HttpClient, @@ -57,11 +58,52 @@ export class VideoCommentService { ) } - getAdminVideoComments (options: { + // --------------------------------------------------------------------------- + + approveComments (comments: { + videoId: number + commentId: number + }[]) { + return from(comments) + .pipe( + concatMap(({ videoId, commentId }) => { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + commentId + '/approve' + + return this.authHttp.post(url, {}) + .pipe(catchError(err => this.restExtractor.handleError(err))) + }), + toArray() + ) + } + + // --------------------------------------------------------------------------- + + listVideoCommentsOfMyVideos (options: { pagination: RestPagination sort: SortMeta search?: string - }): Observable<ResultList<VideoCommentAdmin>> { + }): Observable<ResultList<VideoCommentForAdminOrUser>> { + const { pagination, sort, search } = options + const url = VideoCommentService.BASE_ME_URL + 'videos/comments' + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) { + params = this.buildParamsFromSearch(search, params) + } + + return this.authHttp.get<ResultList<VideoCommentForAdminOrUser>>(url, { params }) + .pipe( + catchError(res => this.restExtractor.handleError(res)) + ) + } + + listAdminVideoComments (options: { + pagination: RestPagination + sort: SortMeta + search?: string + }): Observable<ResultList<VideoCommentForAdminOrUser>> { const { pagination, sort, search } = options const url = VideoCommentService.BASE_VIDEO_URL + 'comments' @@ -72,12 +114,14 @@ export class VideoCommentService { params = this.buildParamsFromSearch(search, params) } - return this.authHttp.get<ResultList<VideoCommentAdmin>>(url, { params }) + return this.authHttp.get<ResultList<VideoCommentForAdminOrUser>>(url, { params }) .pipe( catchError(res => this.restExtractor.handleError(res)) ) } + // --------------------------------------------------------------------------- + getVideoCommentThreads (parameters: { videoId: string videoPassword: string @@ -118,6 +162,8 @@ export class VideoCommentService { ) } + // --------------------------------------------------------------------------- + deleteVideoComment (videoId: number | string, commentId: number) { const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` @@ -134,6 +180,8 @@ export class VideoCommentService { ) } + // --------------------------------------------------------------------------- + getVideoCommentsFeeds (video: Pick<Video, 'uuid'>) { const feeds = [ { @@ -204,6 +252,16 @@ export class VideoCommentService { isBoolean: true }, + isHeldForReview: { + prefix: 'heldForReview:', + isBoolean: true + }, + + autoTagOneOf: { + prefix: 'autoTag:', + multiple: true + }, + searchAccount: { prefix: 'account:' }, searchVideo: { prefix: 'video:' } }) diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts index 5d6362aac..c593ceeeb 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts +++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts @@ -58,7 +58,7 @@ export class VideoPlaylistService { ) { this.videoExistsInPlaylistObservable = merge( buildBulkObservable({ - time: 500, + time: 5000, bulkGet: (videoIds: number[]) => { // We added a delay to the request, so ensure the user is still logged in if (this.auth.isLoggedIn()) { diff --git a/client/src/app/shared/standalone-notifications/user-notifications.component.html b/client/src/app/shared/standalone-notifications/user-notifications.component.html index 86165bb77..d3367ede9 100644 --- a/client/src/app/shared/standalone-notifications/user-notifications.component.html +++ b/client/src/app/shared/standalone-notifications/user-notifications.component.html @@ -94,6 +94,7 @@ <div class="message" i18n> <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a> + <ng-container *ngIf="notification.comment.heldForReview">. This comment requires <a (click)="markAsRead(notification)" [routerLink]="notification.commentReviewUrl" [queryParams]="notification.commentReviewQueryParams">your approval</a></ng-container> </div> </ng-container> diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html new file mode 100644 index 000000000..f47cc1cfd --- /dev/null +++ b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html @@ -0,0 +1,86 @@ +<p-table + [value]="lists" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" + [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" + [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="false" + [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" + [expandedRowKeys]="expandedRows" +> + <ng-template pTemplate="caption"> + <div class="caption"> + <div class="left-buttons"> + <button type="button" *ngIf="!isInSelectionMode()" class="peertube-create-button" (click)="openCreateOrUpdateList()"> + <my-global-icon iconName="add" aria-hidden="true"></my-global-icon> + <ng-container i18n>Create a new list</ng-container> + </button> + </div> + + <div class="ms-auto right-form"> + <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button> + </div> + </div> + </ng-template> + + <ng-template pTemplate="header"> + <tr> + <th scope="col" style="width: 40px;"> + <span i18n class="visually-hidden">More information</span> + </th> + <th scope="col" style="width: 150px;"> + <span i18n class="visually-hidden">Actions</span> + </th> + <th scope="col" style="width: 300px;" i18n>List name</th> + <th scope="col" style="width: 300px;" i18n>Words</th> + <th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="updatedAt">Date <p-sortIcon field="updatedAt"></p-sortIcon></th> + </tr> + </ng-template> + + <ng-template pTemplate="body" let-list let-expanded="expanded"> + <tr> + + <td class="expand-cell"> + <my-table-expander-icon [pRowToggler]="list" i18n-tooltip tooltip="See all words" [expanded]="expanded"></my-table-expander-icon> + </td> + + <td class="action-cell"> + <my-action-dropdown + [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body" + i18n-label label="Actions" [actions]="actions" [entry]="list" + ></my-action-dropdown> + </td> + + <td> + {{ list.listName }} + </td> + + <td i18n> + {{ list.words.length }} words + </td> + + <td>{{ list.updatedAt | date: 'short' }}</td> + </tr> + </ng-template> + + <ng-template pTemplate="rowexpansion" let-list> + <tr> + <td class="expand-cell" myAutoColspan> + <ul> + @for (word of list.words; track word) { + <li>{{ word }}</li> + } + </ul> + </td> + </tr> + </ng-template> + + <ng-template pTemplate="emptymessage"> + <tr> + <td myAutoColspan> + <div class="no-results"> + <ng-container i18n>No watched word lists found.</ng-container> + </div> + </td> + </tr> + </ng-template> +</p-table> + +<my-watched-words-list-save-modal #saveModal [accountName]="accountNameParam" (listAddedOrUpdated)="reloadData()"></my-watched-words-list-save-modal> diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts new file mode 100644 index 000000000..6b441cb19 --- /dev/null +++ b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts @@ -0,0 +1,138 @@ +import { DatePipe, NgClass, NgIf } from '@angular/common' +import { Component, Input, OnInit, ViewChild } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' +import { UserRight, WatchedWordsList } from '@peertube/peertube-models' +import { SharedModule, SortMeta } from 'primeng/api' +import { TableModule } from 'primeng/table' +import { first } from 'rxjs' +import { GlobalIconComponent } from '../shared-icons/global-icon.component' +import { AutoColspanDirective } from '../shared-main/angular/auto-colspan.directive' +import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component' +import { ButtonComponent } from '../shared-main/buttons/button.component' +import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon.component' +import { WatchedWordsListSaveModalComponent } from './watched-words-list-save-modal.component' +import { WatchedWordsListService } from './watched-words-list.service' + +@Component({ + selector: 'my-watched-words-list-admin-owner', + templateUrl: './watched-words-list-admin-owner.component.html', + standalone: true, + imports: [ + GlobalIconComponent, + TableModule, + SharedModule, + NgIf, + ActionDropdownComponent, + ButtonComponent, + TableExpanderIconComponent, + NgClass, + AutoColspanDirective, + DatePipe, + NgbTooltip, + WatchedWordsListSaveModalComponent + ] +}) +export class WatchedWordsListAdminOwnerComponent extends RestTable<WatchedWordsList> implements OnInit { + @Input({ required: true }) mode: 'user' | 'admin' + + @ViewChild('saveModal', { static: true }) saveModal: WatchedWordsListSaveModalComponent + + lists: WatchedWordsList[] + totalRecords = 0 + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + actions: DropdownAction<WatchedWordsList>[][] = [] + + get authUser () { + return this.auth.getUser() + } + + get accountNameParam () { + if (this.mode === 'admin') return undefined + + return this.authUser.account.name + } + + constructor ( + protected router: Router, + protected route: ActivatedRoute, + private auth: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private watchedWordsListService: WatchedWordsListService + ) { + super() + + const isDisplayed = () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS) + + this.actions = [ + [ + { + iconName: 'edit', + label: $localize`Update`, + handler: list => this.openCreateOrUpdateList(list), + isDisplayed + } + ], + [ + { + iconName: 'delete', + label: $localize`Delete`, + handler: list => this.removeList(list), + isDisplayed + } + ] + ] + } + + ngOnInit () { + this.initialize() + + this.auth.userInformationLoaded + .pipe(first()) + .subscribe(() => this.reloadData()) + } + + getIdentifier () { + return 'WatchedWordsListAdminOwnerComponent' + } + + openCreateOrUpdateList (list?: WatchedWordsList) { + this.saveModal.show(list) + } + + protected reloadDataInternal () { + this.watchedWordsListService.list({ pagination: this.pagination, sort: this.sort, accountName: this.accountNameParam }) + .subscribe({ + next: resultList => { + this.totalRecords = resultList.total + this.lists = resultList.data + }, + + error: err => this.notifier.error(err.message) + }) + } + + private async removeList (list: WatchedWordsList) { + const message = $localize`Are you sure you want to delete this ${list.listName} list?` + const res = await this.confirmService.confirm(message, $localize`Delete list`) + if (res === false) return + + this.watchedWordsListService.deleteList({ + listId: list.id, + accountName: this.accountNameParam + }).subscribe({ + next: () => { + this.notifier.success($localize`${list.listName} removed`) + + this.reloadData() + }, + + error: err => this.notifier.error(err.message) + }) + } + +} diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html new file mode 100644 index 000000000..0c3b02a77 --- /dev/null +++ b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html @@ -0,0 +1,49 @@ +<ng-template #modal> + <ng-container [formGroup]="form"> + + <div class="modal-header"> + <h4 i18n class="modal-title">Save watched words list</h4> + + <button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()"> + <my-global-icon iconName="cross"></my-global-icon> + </button> + </div> + + <div class="modal-body"> + + <div class="form-group"> + <label i18n for="listName">List name</label> + + <input + type="text" id="listName" class="form-control" + formControlName="listName" [ngClass]="{ 'input-error': formErrors['listName'] }" + > + + <div *ngIf="formErrors.listName" class="form-error" role="alert">{{ formErrors.listName }}</div> + </div> + + <div class="form-group"> + <label i18n for="words">Words</label> + + <div i18n class="form-group-description">One word or group of words per line.</div> + + <textarea id="words" formControlName="words" class="form-control"[ngClass]="{ 'input-error': formErrors['words'] }"></textarea> + + <div *ngIf="formErrors.words" class="form-error" role="alert">{{ formErrors.words }}</div> + </div> + + </div> + + <div class="modal-footer inputs"> + <input + type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button" + (click)="hide()" (key.enter)="hide()" + > + + <input + type="submit" i18n-value value="Save" class="peertube-button orange-button" + [disabled]="!form.valid" (click)="addOrUpdate()" + > + </div> + </ng-container> +</ng-template> diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss new file mode 100644 index 000000000..59e731b79 --- /dev/null +++ b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss @@ -0,0 +1,6 @@ +@use '_variables' as *; +@use '_mixins' as *; + +textarea { + min-height: 300px; +} diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts new file mode 100644 index 000000000..ed2d90c61 --- /dev/null +++ b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts @@ -0,0 +1,95 @@ +import { NgClass, NgIf } from '@angular/common' +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { Notifier } from '@app/core' +import { FormReactive } from '@app/shared/shared-forms/form-reactive' +import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' +import { WatchedWordsList } from '@peertube/peertube-models' +import { splitAndGetNotEmpty } from '@root-helpers/string' +import { UNIQUE_WATCHED_WORDS_VALIDATOR, WATCHED_WORDS_LIST_NAME_VALIDATOR } from '../form-validators/watched-words-list-validators' +import { GlobalIconComponent } from '../shared-icons/global-icon.component' +import { WatchedWordsListService } from './watched-words-list.service' + +@Component({ + selector: 'my-watched-words-list-save-modal', + styleUrls: [ './watched-words-list-save-modal.component.scss' ], + templateUrl: './watched-words-list-save-modal.component.html', + standalone: true, + imports: [ FormsModule, ReactiveFormsModule, GlobalIconComponent, NgIf, NgClass ] +}) + +export class WatchedWordsListSaveModalComponent extends FormReactive implements OnInit { + @Input({ required: true }) accountName: string + + @Output() listAddedOrUpdated = new EventEmitter<void>() + + @ViewChild('modal', { static: true }) modal: ElementRef + + private openedModal: NgbModalRef + private listToUpdate: WatchedWordsList + + constructor ( + protected formReactiveService: FormReactiveService, + private modalService: NgbModal, + private notifier: Notifier, + private watchedWordsService: WatchedWordsListService + ) { + super() + } + + ngOnInit () { + this.buildForm({ + listName: WATCHED_WORDS_LIST_NAME_VALIDATOR, + words: UNIQUE_WATCHED_WORDS_VALIDATOR + }) + } + + show (list?: WatchedWordsList) { + this.listToUpdate = list + + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) + + if (list) { + this.form.patchValue({ + listName: list.listName, + words: list.words.join('\n') + }) + } + } + + hide () { + this.openedModal.close() + this.form.reset() + + this.listToUpdate = undefined + } + + addOrUpdate () { + const commonParams = { + accountName: this.accountName, + listName: this.form.value['listName'], + words: splitAndGetNotEmpty(this.form.value['words']) + } + + const obs = this.listToUpdate + ? this.watchedWordsService.updateList({ ...commonParams, listId: this.listToUpdate.id }) + : this.watchedWordsService.addList(commonParams) + + obs.subscribe({ + next: () => { + if (this.listToUpdate) { + this.notifier.success($localize`${commonParams.listName} updated`) + } else { + this.notifier.success($localize`${commonParams.listName} created`) + } + + this.listAddedOrUpdated.emit() + }, + + error: err => this.notifier.error(err.message) + }) + + this.hide() + } +} diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list.service.ts b/client/src/app/shared/standalone-watched-words/watched-words-list.service.ts new file mode 100644 index 000000000..baf263e46 --- /dev/null +++ b/client/src/app/shared/standalone-watched-words/watched-words-list.service.ts @@ -0,0 +1,86 @@ +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ResultList, WatchedWordsList } from '@peertube/peertube-models' +import { SortMeta } from 'primeng/api' +import { Observable } from 'rxjs' +import { catchError } from 'rxjs/operators' +import { environment } from '../../../environments/environment' + +@Injectable() +export class WatchedWordsListService { + private static BASE_WATCHED_WORDS_URL = environment.apiUrl + '/api/v1/watched-words/' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) {} + + list (options: { + accountName?: string + pagination: RestPagination + sort: SortMeta + }): Observable<ResultList<WatchedWordsList>> { + const { pagination, sort } = options + const url = this.buildServerOrAccountListPath(options) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp.get<ResultList<WatchedWordsList>>(url, { params }) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + addList (options: { + accountName?: string + listName: string + words: string[] + }) { + const { listName, words } = options + + const url = this.buildServerOrAccountListPath(options) + const body = { listName, words } + + return this.authHttp.post(url, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + updateList (options: { + accountName?: string + + listId: number + listName: string + words: string[] + }) { + const { listName, words } = options + + const url = this.buildServerOrAccountListPath(options) + const body = { listName, words } + + return this.authHttp.put(url, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + deleteList (options: { + accountName?: string + listId: number + }) { + const url = this.buildServerOrAccountListPath(options) + + return this.authHttp.delete(url) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + private buildServerOrAccountListPath (options: { accountName?: string, listId?: number }) { + let suffixPath = options.accountName + ? '/accounts/' + options.accountName + '/lists' + : '/server/lists' + + if (options.listId) { + suffixPath += '/' + options.listId + } + + return WatchedWordsListService.BASE_WATCHED_WORDS_URL + suffixPath + } +} diff --git a/client/src/root-helpers/string.ts b/client/src/root-helpers/string.ts index 46439b535..a3055da56 100644 --- a/client/src/root-helpers/string.ts +++ b/client/src/root-helpers/string.ts @@ -15,3 +15,9 @@ export function randomString (length: number) { return result } + +export function splitAndGetNotEmpty (value: string) { + return value + .split('\n') + .filter(line => line && line.length !== 0) // Eject empty lines +} diff --git a/config/default.yaml b/config/default.yaml index e543d0378..cb6d49fd2 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -123,7 +123,8 @@ defaults: publish: download_enabled: true - comments_enabled: true + # enabled = 1, disabled = 2, requires_approval = 3 + comments_policy: 1 # public = 1, unlisted = 2, private = 3, internal = 4 privacy: 1 diff --git a/config/production.yaml.example b/config/production.yaml.example index 254dd113f..9d2fe08be 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -121,7 +121,8 @@ defaults: publish: download_enabled: true - comments_enabled: true + # enabled = 1, disabled = 2, requires_approval = 3 + comments_policy: 1 # public = 1, unlisted = 2, private = 3, internal = 4 privacy: 1 diff --git a/package.json b/package.json index fa009c8e4..48323ea3c 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "js-yaml": "^4.0.0", "jsonld": "~8.3.1", "jsonwebtoken": "^9.0.2", + "linkify-it": "^5.0.0", "lodash-es": "^4.17.21", "lru-cache": "^10.0.1", "magnet-uri": "^7.0.5", @@ -201,6 +202,7 @@ "@types/fs-extra": "^11.0.1", "@types/jsonld": "^1.5.9", "@types/jsonwebtoken": "^9.0.5", + "@types/linkify-it": "^3.0.5", "@types/lodash-es": "^4.17.8", "@types/magnet-uri": "^5.1.1", "@types/maildev": "^0.0.7", diff --git a/packages/core-utils/src/users/user-role.ts b/packages/core-utils/src/users/user-role.ts index 0add3a0a8..64c120b37 100644 --- a/packages/core-utils/src/users/user-role.ts +++ b/packages/core-utils/src/users/user-role.ts @@ -17,14 +17,16 @@ const userRoleRights: { [ id in UserRoleType ]: UserRightType[] } = { UserRight.MANAGE_ANY_VIDEO_CHANNEL, UserRight.REMOVE_ANY_VIDEO, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, - UserRight.REMOVE_ANY_VIDEO_COMMENT, + UserRight.MANAGE_ANY_VIDEO_COMMENT, UserRight.UPDATE_ANY_VIDEO, UserRight.SEE_ALL_VIDEOS, UserRight.MANAGE_ACCOUNTS_BLOCKLIST, UserRight.MANAGE_SERVERS_BLOCKLIST, UserRight.MANAGE_USERS, UserRight.SEE_ALL_COMMENTS, - UserRight.MANAGE_REGISTRATIONS + UserRight.MANAGE_REGISTRATIONS, + UserRight.MANAGE_INSTANCE_WATCHED_WORDS, + UserRight.MANAGE_INSTANCE_AUTO_TAGS ], [UserRole.USER]: [] diff --git a/packages/models/src/activitypub/activity.ts b/packages/models/src/activitypub/activity.ts index 22f9edd78..9e6402c08 100644 --- a/packages/models/src/activitypub/activity.ts +++ b/packages/models/src/activitypub/activity.ts @@ -33,7 +33,9 @@ export type Activity = ActivityReject | ActivityView | ActivityDislike | - ActivityFlag + ActivityFlag | + ActivityApproveReply | + ActivityRejectReply export type ActivityType = 'Create' | @@ -47,7 +49,9 @@ export type ActivityType = 'Reject' | 'View' | 'Dislike' | - 'Flag' + 'Flag' | + 'ApproveReply' | + 'RejectReply' export interface ActivityAudience { to: string[] @@ -89,6 +93,18 @@ export interface ActivityAccept extends BaseActivity { object: ActivityFollow } +export interface ActivityApproveReply extends BaseActivity { + type: 'ApproveReply' + object: string + inReplyTo: string +} + +export interface ActivityRejectReply extends BaseActivity { + type: 'RejectReply' + object: string + inReplyTo: string +} + export interface ActivityReject extends BaseActivity { type: 'Reject' object: ActivityFollow diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts index e52463c6c..f0989698d 100644 --- a/packages/models/src/activitypub/context.ts +++ b/packages/models/src/activitypub/context.ts @@ -14,4 +14,6 @@ export type ContextType = 'Actor' | 'Collection' | 'WatchAction' | - 'Chapters' + 'Chapters' | + 'ApproveReply' | + 'RejectReply' diff --git a/packages/models/src/activitypub/objects/video-comment-object.ts b/packages/models/src/activitypub/objects/video-comment-object.ts index 880dd2ee2..63260d225 100644 --- a/packages/models/src/activitypub/objects/video-comment-object.ts +++ b/packages/models/src/activitypub/objects/video-comment-object.ts @@ -13,4 +13,9 @@ export interface VideoCommentObject { url: string attributedTo: ActivityPubAttributedTo tag: ActivityTagObject[] + + replyApproval: string | null + + to?: string[] + cc?: string[] } diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts index 16dbe1aab..63177c239 100644 --- a/packages/models/src/activitypub/objects/video-object.ts +++ b/packages/models/src/activitypub/objects/video-object.ts @@ -1,4 +1,4 @@ -import { LiveVideoLatencyModeType, VideoStateType } from '../../videos/index.js' +import { LiveVideoLatencyModeType, VideoCommentPolicyType, VideoStateType } from '../../videos/index.js' import { ActivityIconObject, ActivityIdentifierObject, @@ -29,7 +29,10 @@ export interface VideoObject { permanentLive: boolean latencyMode: LiveVideoLatencyModeType - commentsEnabled: boolean + commentsEnabled?: boolean + commentsPolicy: VideoCommentPolicyType + canReply: 'as:Public' | 'https://www.w3.org/ns/activitystreams#Public' + downloadEnabled: boolean waitTranscoding: boolean state: VideoStateType diff --git a/packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts b/packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts new file mode 100644 index 000000000..48796c615 --- /dev/null +++ b/packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts @@ -0,0 +1,5 @@ +export interface AutoTagPoliciesJSON { + reviewComments: { + name: string + }[] +} diff --git a/packages/models/src/import-export/peertube-export-format/index.ts b/packages/models/src/import-export/peertube-export-format/index.ts index c0cbbf4b8..3ecec47eb 100644 --- a/packages/models/src/import-export/peertube-export-format/index.ts +++ b/packages/models/src/import-export/peertube-export-format/index.ts @@ -1,5 +1,6 @@ export * from './account-export.model.js' export * from './actor-export.model.js' +export * from './auto-tag-policies-export.js' export * from './blocklist-export.model.js' export * from './channel-export.model.js' export * from './comments-export.model.js' @@ -11,3 +12,4 @@ export * from './user-settings-export.model.js' export * from './user-video-history-export.js' export * from './video-export.model.js' export * from './video-playlists-export.model.js' +export * from './watched-words-lists-export.js' diff --git a/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts b/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts index 11e92f356..8e40b2e16 100644 --- a/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts +++ b/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts @@ -4,7 +4,7 @@ export interface UserVideoHistoryExportJSON { lastTimecode: number createdAt: string updatedAt: string - }[] - archiveFiles?: never + archiveFiles?: never + }[] } diff --git a/packages/models/src/import-export/peertube-export-format/video-export.model.ts b/packages/models/src/import-export/peertube-export-format/video-export.model.ts index a23a015ac..6e49c6193 100644 --- a/packages/models/src/import-export/peertube-export-format/video-export.model.ts +++ b/packages/models/src/import-export/peertube-export-format/video-export.model.ts @@ -1,5 +1,6 @@ import { LiveVideoLatencyModeType, + VideoCommentPolicyType, VideoFileMetadata, VideoPrivacyType, VideoStateType, @@ -53,7 +54,10 @@ export interface VideoExportJSON { nsfw: boolean - commentsEnabled: boolean + // TODO: remove, deprecated in 6.2 + commentsEnabled?: boolean + commentsPolicy: VideoCommentPolicyType + downloadEnabled: boolean channel: { diff --git a/packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts b/packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts new file mode 100644 index 000000000..3c0dadc4d --- /dev/null +++ b/packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts @@ -0,0 +1,10 @@ +export interface WatchedWordsListsJSON { + watchedWordLists: { + createdAt: string + updatedAt: string + listName: string + words: string[] + + archiveFiles?: never + }[] +} diff --git a/packages/models/src/import-export/user-import-result.model.ts b/packages/models/src/import-export/user-import-result.model.ts index 156725c42..73c0c4930 100644 --- a/packages/models/src/import-export/user-import-result.model.ts +++ b/packages/models/src/import-export/user-import-result.model.ts @@ -18,5 +18,8 @@ export interface UserImportResultSummary { userSettings: Summary userVideoHistory: Summary + + watchedWordsLists: Summary + commentAutoTagPolicies: Summary } } diff --git a/packages/models/src/moderation/automatic-tag-available.model.ts b/packages/models/src/moderation/automatic-tag-available.model.ts new file mode 100644 index 000000000..407866316 --- /dev/null +++ b/packages/models/src/moderation/automatic-tag-available.model.ts @@ -0,0 +1,8 @@ +export type AutomaticTagAvailableType = 'core' | 'watched-words-list' + +export interface AutomaticTagAvailable { + available: { + name: string + type: AutomaticTagAvailableType + }[] +} diff --git a/packages/models/src/moderation/automatic-tag-policy.enum.ts b/packages/models/src/moderation/automatic-tag-policy.enum.ts new file mode 100644 index 000000000..38d8f41fc --- /dev/null +++ b/packages/models/src/moderation/automatic-tag-policy.enum.ts @@ -0,0 +1,6 @@ +export const AutomaticTagPolicy = { + NONE: 1, + REVIEW_COMMENT: 2 +} as const + +export type AutomaticTagPolicyType = typeof AutomaticTagPolicy[keyof typeof AutomaticTagPolicy] diff --git a/packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts b/packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts new file mode 100644 index 000000000..2c0f0efbf --- /dev/null +++ b/packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts @@ -0,0 +1,3 @@ +export interface CommentAutomaticTagPoliciesUpdate { + review: string[] +} diff --git a/packages/models/src/moderation/comment-automatic-tag-policies.model.ts b/packages/models/src/moderation/comment-automatic-tag-policies.model.ts new file mode 100644 index 000000000..61def2b02 --- /dev/null +++ b/packages/models/src/moderation/comment-automatic-tag-policies.model.ts @@ -0,0 +1,3 @@ +export interface CommentAutomaticTagPolicies { + review: string[] +} diff --git a/packages/models/src/moderation/index.ts b/packages/models/src/moderation/index.ts index 52e21e7b3..fb27da7e0 100644 --- a/packages/models/src/moderation/index.ts +++ b/packages/models/src/moderation/index.ts @@ -1,4 +1,9 @@ export * from './abuse/index.js' -export * from './block-status.model.js' +export * from './automatic-tag-available.model.js' export * from './account-block.model.js' +export * from './comment-automatic-tag-policies-update.model.js' +export * from './comment-automatic-tag-policies.model.js' +export * from './automatic-tag-policy.enum.js' +export * from './block-status.model.js' export * from './server-block.model.js' +export * from './watched-words-list.model.js' diff --git a/packages/models/src/moderation/watched-words-list.model.ts b/packages/models/src/moderation/watched-words-list.model.ts new file mode 100644 index 000000000..c0312e86d --- /dev/null +++ b/packages/models/src/moderation/watched-words-list.model.ts @@ -0,0 +1,9 @@ +export interface WatchedWordsList { + id: number + + listName: string + words: string[] + + updatedAt: Date | string + createdAt: Date | string +} diff --git a/packages/models/src/search/videos-common-query.model.ts b/packages/models/src/search/videos-common-query.model.ts index 45181a739..e8f327c94 100644 --- a/packages/models/src/search/videos-common-query.model.ts +++ b/packages/models/src/search/videos-common-query.model.ts @@ -21,8 +21,6 @@ export interface VideosCommonQuery { languageOneOf?: string[] - privacyOneOf?: VideoPrivacyType[] - tagsOneOf?: string[] tagsAllOf?: string[] @@ -36,6 +34,10 @@ export interface VideosCommonQuery { search?: string excludeAlreadyWatched?: boolean + + // Only available with special user right + autoTagOneOf?: string[] + privacyOneOf?: VideoPrivacyType[] } export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery { diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts index 9106c5b95..2e3626854 100644 --- a/packages/models/src/server/server-config.model.ts +++ b/packages/models/src/server/server-config.model.ts @@ -1,4 +1,4 @@ -import { ActorImage } from '../index.js' +import { ActorImage, VideoCommentPolicyType } from '../index.js' import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { VideoPrivacyType } from '../videos/video-privacy.enum.js' @@ -57,7 +57,11 @@ export interface ServerConfig { defaults: { publish: { downloadEnabled: boolean + + // TODO: remove, deprecated in 6.2 commentsEnabled: boolean + commentsPolicy: VideoCommentPolicyType + privacy: VideoPrivacyType licence: number } diff --git a/packages/models/src/users/user-notification.model.ts b/packages/models/src/users/user-notification.model.ts index 74cc16adb..e8435f031 100644 --- a/packages/models/src/users/user-notification.model.ts +++ b/packages/models/src/users/user-notification.model.ts @@ -85,6 +85,7 @@ export interface UserNotification { threadId: number account: ActorInfo video: VideoInfo + heldForReview: boolean } abuse?: { diff --git a/packages/models/src/users/user-right.enum.ts b/packages/models/src/users/user-right.enum.ts index 1da8b64f9..fe8e32308 100644 --- a/packages/models/src/users/user-right.enum.ts +++ b/packages/models/src/users/user-right.enum.ts @@ -26,7 +26,7 @@ export const UserRight = { REMOVE_ANY_VIDEO: 14, REMOVE_ANY_VIDEO_PLAYLIST: 15, - REMOVE_ANY_VIDEO_COMMENT: 16, + MANAGE_ANY_VIDEO_COMMENT: 16, UPDATE_ANY_VIDEO: 17, UPDATE_ANY_VIDEO_PLAYLIST: 18, @@ -50,7 +50,10 @@ export const UserRight = { MANAGE_RUNNERS: 29, MANAGE_USER_EXPORTS: 30, - MANAGE_USER_IMPORTS: 31 + MANAGE_USER_IMPORTS: 31, + + MANAGE_INSTANCE_WATCHED_WORDS: 32, + MANAGE_INSTANCE_AUTO_TAGS: 33 } as const export type UserRightType = typeof UserRight[keyof typeof UserRight] diff --git a/packages/models/src/videos/comment/index.ts b/packages/models/src/videos/comment/index.ts index bd26c652d..0c251fce4 100644 --- a/packages/models/src/videos/comment/index.ts +++ b/packages/models/src/videos/comment/index.ts @@ -1,2 +1,3 @@ export * from './video-comment-create.model.js' export * from './video-comment.model.js' +export * from './video-comment-policy.enum.js' diff --git a/packages/models/src/videos/comment/video-comment-policy.enum.ts b/packages/models/src/videos/comment/video-comment-policy.enum.ts new file mode 100644 index 000000000..d4664305c --- /dev/null +++ b/packages/models/src/videos/comment/video-comment-policy.enum.ts @@ -0,0 +1,7 @@ +export const VideoCommentPolicy = { + ENABLED: 1, + DISABLED: 2, + REQUIRES_APPROVAL: 3 +} as const + +export type VideoCommentPolicyType = typeof VideoCommentPolicy[keyof typeof VideoCommentPolicy] diff --git a/packages/models/src/videos/comment/video-comment.model.ts b/packages/models/src/videos/comment/video-comment.model.ts index e2266545a..c818cb736 100644 --- a/packages/models/src/videos/comment/video-comment.model.ts +++ b/packages/models/src/videos/comment/video-comment.model.ts @@ -5,19 +5,25 @@ export interface VideoComment { id: number url: string text: string + threadId: number inReplyToCommentId: number videoId: number + createdAt: Date | string updatedAt: Date | string deletedAt: Date | string + isDeleted: boolean totalRepliesFromVideoAuthor: number totalReplies: number + account: Account + + heldForReview: boolean } -export interface VideoCommentAdmin { +export interface VideoCommentForAdminOrUser { id: number url: string text: string @@ -35,6 +41,10 @@ export interface VideoCommentAdmin { uuid: string name: string } + + heldForReview: boolean + + automaticTags: string[] } export type VideoCommentThreads = ResultList<VideoComment> & { totalNotDeletedComments: number } diff --git a/packages/models/src/videos/video-create.model.ts b/packages/models/src/videos/video-create.model.ts index 472201211..0eca38372 100644 --- a/packages/models/src/videos/video-create.model.ts +++ b/packages/models/src/videos/video-create.model.ts @@ -1,3 +1,4 @@ +import { VideoCommentPolicyType } from './comment/video-comment-policy.enum.js' import { VideoPrivacyType } from './video-privacy.enum.js' import { VideoScheduleUpdate } from './video-schedule-update.model.js' @@ -13,7 +14,11 @@ export interface VideoCreate { nsfw?: boolean waitTranscoding?: boolean tags?: string[] + + // TODO: remove, deprecated in 6.2 commentsEnabled?: boolean + commentsPolicy?: VideoCommentPolicyType + downloadEnabled?: boolean privacy: VideoPrivacyType scheduleUpdate?: VideoScheduleUpdate diff --git a/packages/models/src/videos/video-include.enum.ts b/packages/models/src/videos/video-include.enum.ts index de8763a85..72f3d0b14 100644 --- a/packages/models/src/videos/video-include.enum.ts +++ b/packages/models/src/videos/video-include.enum.ts @@ -5,7 +5,8 @@ export const VideoInclude = { BLOCKED_OWNER: 1 << 2, FILES: 1 << 3, CAPTIONS: 1 << 4, - SOURCE: 1 << 5 + SOURCE: 1 << 5, + AUTOMATIC_TAGS: 1 << 6 } as const export type VideoIncludeType = typeof VideoInclude[keyof typeof VideoInclude] diff --git a/packages/models/src/videos/video-update.model.ts b/packages/models/src/videos/video-update.model.ts index 8af298160..5e7580a44 100644 --- a/packages/models/src/videos/video-update.model.ts +++ b/packages/models/src/videos/video-update.model.ts @@ -1,3 +1,4 @@ +import { VideoCommentPolicyType } from './index.js' import { VideoPrivacyType } from './video-privacy.enum.js' import { VideoScheduleUpdate } from './video-schedule-update.model.js' @@ -10,7 +11,11 @@ export interface VideoUpdate { support?: string privacy?: VideoPrivacyType tags?: string[] + + // TODO: remove, deprecated in 6.2 commentsEnabled?: boolean + commentsPolicy?: VideoCommentPolicyType + downloadEnabled?: boolean nsfw?: boolean waitTranscoding?: boolean diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts index f29efecd1..5b37e0a43 100644 --- a/packages/models/src/videos/video.model.ts +++ b/packages/models/src/videos/video.model.ts @@ -1,6 +1,7 @@ import { Account, AccountSummary } from '../actors/index.js' import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js' import { VideoFile } from './file/index.js' +import { VideoCommentPolicyType } from './index.js' import { VideoConstant } from './video-constant.model.js' import { VideoPrivacyType } from './video-privacy.enum.js' import { VideoScheduleUpdate } from './video-schedule-update.model.js' @@ -78,17 +79,26 @@ export interface VideoAdditionalAttributes { streamingPlaylists: VideoStreamingPlaylist[] videoSource: VideoSource + + automaticTags: string[] } export interface VideoDetails extends Video { - // Deprecated in 5.0 + // TODO: remove, deprecated in 5.0 descriptionPath: string support: string channel: VideoChannel account: Account tags: string[] + + // TODO: remove, deprecated in 6.2 commentsEnabled: boolean + commentsPolicy: { + id: VideoCommentPolicyType + label: string + } + downloadEnabled: boolean // Not optional in details (unlike in parent Video) diff --git a/packages/server-commands/src/moderation/automatic-tags-command.ts b/packages/server-commands/src/moderation/automatic-tags-command.ts new file mode 100644 index 000000000..fb6dfaa88 --- /dev/null +++ b/packages/server-commands/src/moderation/automatic-tags-command.ts @@ -0,0 +1,68 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + AutomaticTagAvailable, + CommentAutomaticTagPolicies, + CommentAutomaticTagPoliciesUpdate, + HttpStatusCode +} from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class AutomaticTagsCommand extends AbstractCommand { + + getCommentPolicies (options: OverrideCommandOptions & { + accountName: string + }) { + const path = '/api/v1/automatic-tags/policies/accounts/' + options.accountName + '/comments' + + return this.getRequestBody<CommentAutomaticTagPolicies>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateCommentPolicies (options: OverrideCommandOptions & CommentAutomaticTagPoliciesUpdate & { + accountName: string + }) { + const path = '/api/v1/automatic-tags/policies/accounts/' + options.accountName + '/comments' + + return this.putBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'review' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + getAccountAvailable (options: OverrideCommandOptions & { + accountName: string + }) { + const path = '/api/v1/automatic-tags/accounts/' + options.accountName + '/available' + + return this.getRequestBody<AutomaticTagAvailable>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getServerAvailable (options: OverrideCommandOptions = {}) { + const path = '/api/v1/automatic-tags/server/available' + + return this.getRequestBody<AutomaticTagAvailable>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/moderation/index.ts b/packages/server-commands/src/moderation/index.ts index 8164afd7c..c65ba638c 100644 --- a/packages/server-commands/src/moderation/index.ts +++ b/packages/server-commands/src/moderation/index.ts @@ -1 +1,3 @@ export * from './abuses-command.js' +export * from './automatic-tags-command.js' +export * from './watched-words-command.js' diff --git a/packages/server-commands/src/moderation/watched-words-command.ts b/packages/server-commands/src/moderation/watched-words-command.ts new file mode 100644 index 000000000..92566f8e5 --- /dev/null +++ b/packages/server-commands/src/moderation/watched-words-command.ts @@ -0,0 +1,87 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + ResultList, WatchedWordsList +} from '@peertube/peertube-models' +import { unwrapBody } from '../index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class WatchedWordsCommand extends AbstractCommand { + + listWordsLists (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + accountName?: string + }) { + const query = { + sort: '-createdAt', + + ...pick(options, [ 'start', 'count', 'sort' ]) + } + + return this.getRequestBody<ResultList<WatchedWordsList>>({ + ...options, + + path: this.buildAPIBasePath(options.accountName), + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + createList (options: OverrideCommandOptions & { + listName: string + words: string[] + accountName?: string + }) { + const body = pick(options, [ 'listName', 'words' ]) + + return unwrapBody<{ watchedWordsList: { id: number } }>(this.postBodyRequest({ + ...options, + + path: this.buildAPIBasePath(options.accountName), + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + updateList (options: OverrideCommandOptions & { + listId: number + accountName?: string + listName?: string + words?: string[] + }) { + const body = pick(options, [ 'listName', 'words' ]) + + return this.putBodyRequest({ + ...options, + + path: this.buildAPIBasePath(options.accountName) + '/' + options.listId, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + deleteList (options: OverrideCommandOptions & { + listId: number + accountName?: string + }) { + return this.deleteRequest({ + ...options, + + path: this.buildAPIBasePath(options.accountName) + '/' + options.listId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + private buildAPIBasePath (accountName?: string) { + return accountName + ? '/api/v1/watched-words/accounts/' + accountName + '/lists' + : '/api/v1/watched-words/server/lists' + } +} diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts index 8e5e1eaf0..8e27897e2 100644 --- a/packages/server-commands/src/server/server.ts +++ b/packages/server-commands/src/server/server.ts @@ -1,15 +1,15 @@ -import { ChildProcess, fork } from 'child_process' -import { copy } from 'fs-extra/esm' -import { join } from 'path' import { randomInt } from '@peertube/peertube-core-utils' import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails } from '@peertube/peertube-models' import { parallelTests, root } from '@peertube/peertube-node-utils' +import { ChildProcess, fork } from 'child_process' +import { copy } from 'fs-extra/esm' +import { join } from 'path' import { BulkCommand } from '../bulk/index.js' import { CLICommand } from '../cli/index.js' import { CustomPagesCommand } from '../custom-pages/index.js' import { FeedCommand } from '../feeds/index.js' import { LogsCommand } from '../logs/index.js' -import { AbusesCommand } from '../moderation/index.js' +import { AbusesCommand, AutomaticTagsCommand, WatchedWordsCommand } from '../moderation/index.js' import { OverviewsCommand } from '../overviews/index.js' import { RunnerJobsCommand, RunnerRegistrationTokensCommand, RunnersCommand } from '../runners/index.js' import { SearchCommand } from '../search/index.js' @@ -17,35 +17,35 @@ import { SocketIOCommand } from '../socket/index.js' import { AccountsCommand, BlocklistCommand, - UserExportsCommand, LoginCommand, NotificationsCommand, RegistrationsCommand, SubscriptionsCommand, TwoFactorCommand, - UsersCommand, - UserImportsCommand + UserExportsCommand, + UserImportsCommand, + UsersCommand } from '../users/index.js' import { BlacklistCommand, CaptionsCommand, ChangeOwnershipCommand, - ChannelsCommand, ChannelSyncsCommand, + ChannelsCommand, ChaptersCommand, CommentsCommand, HistoryCommand, - VideoImportsCommand, LiveCommand, PlaylistsCommand, ServicesCommand, StoryboardCommand, StreamingPlaylistsCommand, + VideoImportsCommand, VideoPasswordsCommand, - VideosCommand, VideoStatsCommand, VideoStudioCommand, VideoTokenCommand, + VideosCommand, ViewsCommand } from '../videos/index.js' import { ConfigCommand } from './config-command.js' @@ -163,6 +163,9 @@ export class PeerTubeServer { runnerRegistrationTokens?: RunnerRegistrationTokensCommand runnerJobs?: RunnerJobsCommand + watchedWordsLists?: WatchedWordsCommand + autoTags?: AutomaticTagsCommand + constructor (options: { serverNumber: number } | { url: string }) { if ((options as any).url) { this.setUrl((options as any).url) @@ -458,5 +461,8 @@ export class PeerTubeServer { this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) this.runnerJobs = new RunnerJobsCommand(this) this.videoPasswords = new VideoPasswordsCommand(this) + + this.watchedWordsLists = new WatchedWordsCommand(this) + this.autoTags = new AutomaticTagsCommand(this) } } diff --git a/packages/server-commands/src/videos/comments-command.ts b/packages/server-commands/src/videos/comments-command.ts index 4835ae1fb..86ea6332b 100644 --- a/packages/server-commands/src/videos/comments-command.ts +++ b/packages/server-commands/src/videos/comments-command.ts @@ -1,30 +1,45 @@ import { pick } from '@peertube/peertube-core-utils' -import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@peertube/peertube-models' +import { + HttpStatusCode, + ResultList, + VideoComment, + VideoCommentForAdminOrUser, + VideoCommentThreads, + VideoCommentThreadTree +} from '@peertube/peertube-models' import { unwrapBody } from '../requests/index.js' import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' +type ListForAdminOrAccountCommonOptions = { + start?: number + count?: number + sort?: string + search?: string + searchAccount?: string + searchVideo?: string + videoId?: string | number + videoChannelId?: string | number + autoTagOneOf?: string[] +} + export class CommentsCommand extends AbstractCommand { private lastVideoId: number | string private lastThreadId: number private lastReplyId: number - listForAdmin (options: OverrideCommandOptions & { - start?: number - count?: number - sort?: string + listForAdmin (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & { isLocal?: boolean onLocalVideo?: boolean - search?: string - searchAccount?: string - searchVideo?: string } = {}) { - const { sort = '-createdAt' } = options const path = '/api/v1/videos/comments' - const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'onLocalVideo', 'search', 'searchAccount', 'searchVideo' ]) } + const query = { + ...this.buildListForAdminOrAccountQuery(options), + ...pick(options, [ 'isLocal', 'onLocalVideo' ]) + } - return this.getRequestBody<ResultList<VideoComment>>({ + return this.getRequestBody<ResultList<VideoCommentForAdminOrUser>>({ ...options, path, @@ -34,6 +49,35 @@ export class CommentsCommand extends AbstractCommand { }) } + listCommentsOnMyVideos (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & { + isHeldForReview?: boolean + } = {}) { + const path = '/api/v1/users/me/videos/comments' + + return this.getRequestBody<ResultList<VideoCommentForAdminOrUser>>({ + ...options, + + path, + query: { + ...this.buildListForAdminOrAccountQuery(options), + + isHeldForReview: options.isHeldForReview + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private buildListForAdminOrAccountQuery (options: ListForAdminOrAccountCommonOptions) { + return { + sort: '-createdAt', + + ...pick(options, [ 'start', 'count', 'search', 'searchAccount', 'searchVideo', 'sort', 'videoId', 'videoChannelId', 'autoTagOneOf' ]) + } + } + + // --------------------------------------------------------------------------- + listThreads (options: OverrideCommandOptions & { videoId: number | string videoPassword?: string @@ -71,6 +115,16 @@ export class CommentsCommand extends AbstractCommand { }) } + async getThreadOf (options: OverrideCommandOptions & { + videoId: number | string + text: string + }) { + const { videoId, text } = options + const threadId = await this.findCommentId({ videoId, text }) + + return this.getThread({ ...options, videoId, threadId }) + } + async createThread (options: OverrideCommandOptions & { videoId: number | string text: string @@ -136,11 +190,13 @@ export class CommentsCommand extends AbstractCommand { text: string }) { const { videoId, text } = options - const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' }) + const { data } = await this.listForAdmin({ videoId, count: 25, sort: '-createdAt' }) return data.find(c => c.text === text).id } + // --------------------------------------------------------------------------- + delete (options: OverrideCommandOptions & { videoId: number | string commentId: number @@ -156,4 +212,34 @@ export class CommentsCommand extends AbstractCommand { defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 }) } + + async deleteAllComments (options: OverrideCommandOptions & { + videoUUID: string + }) { + const { data } = await this.listForAdmin({ ...options, start: 0, count: 20 }) + + for (const comment of data) { + if (comment?.video.uuid !== options.videoUUID) continue + + await this.delete({ videoId: options.videoUUID, commentId: comment.id, ...options }) + } + } + + // --------------------------------------------------------------------------- + + approve (options: OverrideCommandOptions & { + videoId: number | string + commentId: number + }) { + const { videoId, commentId } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + '/approve' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } } diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts index 090345847..8397b8f4d 100644 --- a/packages/server-commands/src/videos/videos-command.ts +++ b/packages/server-commands/src/videos/videos-command.ts @@ -7,6 +7,7 @@ import { HttpStatusCodeType, ResultList, UserVideoRateType, Video, + VideoCommentPolicy, VideoCreate, VideoCreateResult, VideoDetails, @@ -229,6 +230,7 @@ export class VideosCommand extends AbstractCommand { search?: string isLive?: boolean channelId?: number + autoTagOneOf?: string[] } = {}) { const path = '/api/v1/users/me/videos' @@ -236,7 +238,7 @@ export class VideosCommand extends AbstractCommand { ...options, path, - query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]), + query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId', 'autoTagOneOf' ]), implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 }) @@ -282,7 +284,7 @@ export class VideosCommand extends AbstractCommand { } listAllForAdmin (options: OverrideCommandOptions & VideosCommonQuery = {}) { - const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER + const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER | VideoInclude.AUTOMATIC_TAGS const nsfw = 'both' const privacyOneOf = getAllPrivacies() @@ -429,7 +431,7 @@ export class VideosCommand extends AbstractCommand { support: 'my super support text', tags: [ 'tag' ], privacy: VideoPrivacy.PUBLIC, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, fixture: 'video_short.webm', @@ -619,7 +621,8 @@ export class VideosCommand extends AbstractCommand { 'tagsAllOf', 'isLocal', 'include', - 'skipCount' + 'skipCount', + 'autoTagOneOf' ]) } diff --git a/packages/tests/src/api/check-params/auto-tags.ts b/packages/tests/src/api/check-params/auto-tags.ts new file mode 100644 index 000000000..6ec7ec126 --- /dev/null +++ b/packages/tests/src/api/check-params/auto-tags.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, + createSingleServer, setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test auto tag policies API validator', function () { + let server: PeerTubeServer + + let userToken: string + let userToken2: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + userToken2 = await server.users.generateUserAndToken('user2') + }) + + describe('When getting available account auto tags', function () { + const baseParams = () => ({ accountName: 'user1', token: userToken }) + + it('Should fail without token', async function () { + await server.autoTags.getAccountAvailable({ ...baseParams(), token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a user that cannot manage account', async function () { + await server.autoTags.getAccountAvailable({ ...baseParams(), token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an unknown account', async function () { + await server.autoTags.getAccountAvailable({ ...baseParams(), accountName: 'user42', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await server.autoTags.getAccountAvailable(baseParams()) + }) + }) + + describe('When getting available server auto tags', function () { + + it('Should fail without token', async function () { + await server.autoTags.getServerAvailable({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a user that that does not have enought rights', async function () { + await server.autoTags.getServerAvailable({ token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await server.autoTags.getServerAvailable() + }) + }) + + describe('When getting auto tag policies', function () { + const baseParams = () => ({ accountName: 'user1', token: userToken }) + + it('Should fail without token', async function () { + await server.autoTags.getCommentPolicies({ ...baseParams(), token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a user that cannot manage account', async function () { + await server.autoTags.getCommentPolicies({ ...baseParams(), token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an unknown account', async function () { + await server.autoTags.getCommentPolicies({ ...baseParams(), accountName: 'user42', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await server.autoTags.getCommentPolicies(baseParams()) + }) + }) + + describe('When updating auto tag policies', function () { + const baseParams = () => ({ accountName: 'user1', review: [ 'external-link' ], token: userToken }) + + it('Should fail without token', async function () { + await server.autoTags.updateCommentPolicies({ + ...baseParams(), + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user that cannot manage account', async function () { + await server.autoTags.updateCommentPolicies({ + ...baseParams(), + token: userToken2, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an unknown account', async function () { + await server.autoTags.updateCommentPolicies({ + ...baseParams(), + accountName: 'user42', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with invalid review array', async function () { + await server.autoTags.updateCommentPolicies({ + ...baseParams(), + review: 'toto' as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with review array that does not contain available tags', async function () { + await server.autoTags.updateCommentPolicies({ + ...baseParams(), + review: [ 'toto' ], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct params', async function () { + await server.autoTags.updateCommentPolicies(baseParams()) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts index bf34b5c57..d92210d34 100644 --- a/packages/tests/src/api/check-params/index.ts +++ b/packages/tests/src/api/check-params/index.ts @@ -1,5 +1,6 @@ import './abuses.js' import './accounts.js' +import './auto-tags.js' import './blocklist.js' import './bulk.js' import './channel-import-videos.js' @@ -8,8 +9,6 @@ import './contact-form.js' import './custom-pages.js' import './debug.js' import './follows.js' -import './user-export.js' -import './user-import.js' import './jobs.js' import './live.js' import './logs.js' @@ -24,6 +23,8 @@ import './services.js' import './transcoding.js' import './two-factor.js' import './upload-quota.js' +import './user-export.js' +import './user-import.js' import './user-notifications.js' import './user-subscriptions.js' import './users-admin.js' @@ -37,8 +38,8 @@ import './video-comments.js' import './video-files.js' import './video-imports.js' import './video-playlists.js' -import './video-storyboards.js' import './video-source.js' +import './video-storyboards.js' import './video-studio.js' import './video-token.js' import './videos-common-filters.js' @@ -46,3 +47,4 @@ import './videos-history.js' import './videos-overviews.js' import './videos.js' import './views.js' +import './watched-words.js' diff --git a/packages/tests/src/api/check-params/live.ts b/packages/tests/src/api/check-params/live.ts index f879ba5d4..6793fb6e8 100644 --- a/packages/tests/src/api/check-params/live.ts +++ b/packages/tests/src/api/check-params/live.ts @@ -1,7 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { omit } from '@peertube/peertube-core-utils' -import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + HttpStatusCode, + LiveVideoCreate, + LiveVideoLatencyMode, + VideoCommentPolicy, + VideoCreateResult, + VideoPrivacy +} from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { LiveCommand, @@ -67,7 +74,7 @@ describe('Test video lives API validator', function () { }) describe('When creating a live', function () { - let baseCorrectParams + let baseCorrectParams: LiveVideoCreate before(function () { baseCorrectParams = { @@ -76,7 +83,7 @@ describe('Test video lives API validator', function () { licence: 1, language: 'pt', nsfw: false, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, waitTranscoding: true, description: 'my super description', @@ -120,6 +127,12 @@ describe('Test video lives API validator', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) + it('Should fail with bad comments policy', async function () { + const fields = { ...baseCorrectParams, commentsPolicy: 42 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + it('Should fail with a long description', async function () { const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } diff --git a/packages/tests/src/api/check-params/video-channel-syncs.ts b/packages/tests/src/api/check-params/video-channel-syncs.ts index e6231298c..aff3dde34 100644 --- a/packages/tests/src/api/check-params/video-channel-syncs.ts +++ b/packages/tests/src/api/check-params/video-channel-syncs.ts @@ -285,7 +285,7 @@ describe('Test video channel sync API validator', () => { }) }) - it('should succeed when user delete a sync they own', async function () { + it('Should succeed when user delete a sync they own', async function () { const { videoChannelSync } = await command.create({ attributes: { externalChannelUrl: FIXTURE_URLS.youtubeChannel, diff --git a/packages/tests/src/api/check-params/video-comments.ts b/packages/tests/src/api/check-params/video-comments.ts index 177361606..19eec417c 100644 --- a/packages/tests/src/api/check-params/video-comments.ts +++ b/packages/tests/src/api/check-params/video-comments.ts @@ -1,17 +1,18 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' -import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' import { + PeerTubeServer, cleanupTests, createSingleServer, makeDeleteRequest, makeGetRequest, makePostBodyRequest, - PeerTubeServer, - setAccessTokensToServers + setAccessTokensToServers, + setDefaultVideoChannel } from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { expect } from 'chai' describe('Test video comments API validator', function () { let pathThread: string @@ -36,6 +37,7 @@ describe('Test video comments API validator', function () { server = await createSingleServer(1) await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) { video = await server.videos.upload({ attributes: {} }) @@ -397,9 +399,10 @@ describe('Test video comments API validator', function () { }) describe('When a video has comments disabled', function () { + before(async function () { - video = await server.videos.upload({ attributes: { commentsEnabled: false } }) - pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads' + video = await server.videos.upload({ attributes: { commentsPolicy: VideoCommentPolicy.DISABLED } }) + pathThread = `/api/v1/videos/${video.uuid}/comment-threads` }) it('Should return an empty thread list', async function () { @@ -430,52 +433,133 @@ describe('Test video comments API validator', function () { it('Should return conflict on comment thread add') }) - describe('When listing admin comments threads', function () { - const path = '/api/v1/videos/comments' + describe('When listing admin/user comments', function () { + const paths = [ '/api/v1/videos/comments', '/api/v1/users/me/videos/comments' ] - it('Should fail with a bad start pagination', async function () { - await checkBadStartPagination(server.url, path, server.accessToken) - }) - - it('Should fail with a bad count pagination', async function () { - await checkBadCountPagination(server.url, path, server.accessToken) - }) - - it('Should fail with an incorrect sort', async function () { - await checkBadSortPagination(server.url, path, server.accessToken) + it('Should fail with a bad start/count pagination of invalid sort', async function () { + for (const path of paths) { + await checkBadStartPagination(server.url, path, server.accessToken) + await checkBadCountPagination(server.url, path, server.accessToken) + await checkBadSortPagination(server.url, path, server.accessToken) + } }) it('Should fail with a non authenticated user', async function () { - await makeGetRequest({ - url: server.url, - path, - expectedStatus: HttpStatusCode.UNAUTHORIZED_401 - }) + await server.comments.listForAdmin({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await server.comments.listCommentsOnMyVideos({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) - it('Should fail with a non admin user', async function () { - await makeGetRequest({ - url: server.url, - path, + it('Should fail to list admin comments with a non admin user', async function () { + await server.comments.listForAdmin({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an invalid video', async function () { + await server.comments.listForAdmin({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.comments.listCommentsOnMyVideos({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + await server.comments.listForAdmin({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await server.comments.listCommentsOnMyVideos({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an invalid channel', async function () { + await server.comments.listForAdmin({ videoChannelId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.comments.listCommentsOnMyVideos({ videoChannelId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + await server.comments.listForAdmin({ videoChannelId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await server.comments.listCommentsOnMyVideos({ videoChannelId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail to list comments on my videos with non owned video or channel', async function () { + await server.comments.listCommentsOnMyVideos({ + videoId: video.uuid, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + await server.comments.listCommentsOnMyVideos({ + videoChannelId: server.store.channel.id, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) }) it('Should succeed with the correct params', async function () { - await makeGetRequest({ - url: server.url, - path, - token: server.accessToken, - query: { - isLocal: false, - search: 'toto', - searchAccount: 'toto', - searchVideo: 'toto' - }, - expectedStatus: HttpStatusCode.OK_200 + const base = { + search: 'toto', + searchAccount: 'toto', + searchVideo: 'toto', + videoId: video.uuid, + videoChannelId: server.store.channel.id, + autoTagOneOf: [ 'external-link' ] + } + + await server.comments.listForAdmin({ ...base, isLocal: false }) + await server.comments.listCommentsOnMyVideos(base) + }) + }) + + describe('When approving a comment', function () { + let videoId: string + let commentId: number + let deletedCommentId: number + + before(async function () { + { + const res = await server.videos.upload({ + attributes: { + name: 'review policy', + commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL + } + }) + + videoId = res.uuid + } + + { + const res = await server.comments.createThread({ text: 'thread', videoId, token: userAccessToken }) + commentId = res.id + } + + { + const res = await server.comments.createThread({ text: 'deleted', videoId, token: userAccessToken }) + deletedCommentId = res.id + + await server.comments.delete({ commentId: deletedCommentId, videoId }) + } + }) + + it('Should fail with a non authenticated user', async function () { + await server.comments.approve({ token: 'none', commentId, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another user', async function () { + await server.comments.approve({ token: userAccessToken2, commentId, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an incorrect video', async function () { + await server.comments.approve({ token: userAccessToken2, commentId, videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an incorrect comment', async function () { + await server.comments.approve({ token: userAccessToken2, commentId: 42, videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a deleted comment', async function () { + await server.comments.approve({ + token: userAccessToken, + commentId: deletedCommentId, + videoId, + expectedStatus: HttpStatusCode.CONFLICT_409 }) }) + + it('Should succeed with the correct params', async function () { + await server.comments.approve({ token: userAccessToken, commentId, videoId }) + }) + + it('Should fail with an already held for review comment', async function () { + await server.comments.approve({ token: userAccessToken, commentId, videoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) }) after(async function () { diff --git a/packages/tests/src/api/check-params/video-imports.ts b/packages/tests/src/api/check-params/video-imports.ts index effae487e..354d2c196 100644 --- a/packages/tests/src/api/check-params/video-imports.ts +++ b/packages/tests/src/api/check-params/video-imports.ts @@ -1,21 +1,21 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { omit } from '@peertube/peertube-core-utils' -import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' -import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' -import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' +import { HttpStatusCode, VideoCommentPolicy, VideoImportCreate, VideoPrivacy } from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { + PeerTubeServer, cleanupTests, createSingleServer, makeGetRequest, makePostBodyRequest, makeUploadRequest, - PeerTubeServer, setAccessTokensToServers, setDefaultVideoChannel, waitJobs } from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' describe('Test video imports API validator', function () { const path = '/api/v1/videos/imports' @@ -74,7 +74,7 @@ describe('Test video imports API validator', function () { }) describe('When adding a video import', function () { - let baseCorrectParams + let baseCorrectParams: VideoImportCreate before(function () { baseCorrectParams = { @@ -84,7 +84,7 @@ describe('Test video imports API validator', function () { licence: 1, language: 'pt', nsfw: false, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, waitTranscoding: true, description: 'my super description', @@ -176,6 +176,12 @@ describe('Test video imports API validator', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) + it('Should fail with a bad commentsPolicy', async function () { + const fields = { ...baseCorrectParams, commentsPolicy: 42 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + it('Should fail with a long description', async function () { const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } diff --git a/packages/tests/src/api/check-params/video-passwords.ts b/packages/tests/src/api/check-params/video-passwords.ts index c6e893172..45a080620 100644 --- a/packages/tests/src/api/check-params/video-passwords.ts +++ b/packages/tests/src/api/check-params/video-passwords.ts @@ -1,23 +1,24 @@ -import { expect } from 'chai' import { HttpStatusCode, HttpStatusCodeType, PeerTubeProblemDocument, ServerErrorCode, + VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { + PeerTubeServer, cleanupTests, createSingleServer, makePostBodyRequest, - PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' import { checkUploadVideoParam } from '@tests/shared/videos.js' +import { expect } from 'chai' describe('Test video passwords validator', function () { let path: string @@ -89,7 +90,7 @@ describe('Test video passwords validator', function () { licence: 1, language: 'pt', nsfw: false, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, waitTranscoding: true, description: 'my super description', diff --git a/packages/tests/src/api/check-params/videos-common-filters.ts b/packages/tests/src/api/check-params/videos-common-filters.ts index 8286957b8..95860ba39 100644 --- a/packages/tests/src/api/check-params/videos-common-filters.ts +++ b/packages/tests/src/api/check-params/videos-common-filters.ts @@ -57,6 +57,7 @@ describe('Test video filters validators', function () { isLocal?: boolean include?: VideoIncludeType privacyOneOf?: VideoPrivacyType[] + autoTagOneOf?: string[] expectedStatus: HttpStatusCodeType excludeAlreadyWatched?: boolean unauthenticatedUser?: boolean @@ -81,6 +82,7 @@ describe('Test video filters validators', function () { query: { isLocal: options.isLocal, privacyOneOf: options.privacyOneOf, + autoTagOneOf: options.autoTagOneOf, include: options.include, excludeAlreadyWatched: options.excludeAlreadyWatched, filter: options.filter @@ -110,6 +112,22 @@ describe('Test video filters validators', function () { }) }) + it('Should fail to use autoTagOneOf with a simple user', async function () { + await testEndpoints({ + autoTagOneOf: [ 'test' ], + token: userAccessToken, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed to use autoTagOneOf with a moderator', async function () { + await testEndpoints({ + autoTagOneOf: [ 'test' ], + token: moderatorAccessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + it('Should fail with a bad include', async function () { await testEndpoints({ include: 'toto' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) diff --git a/packages/tests/src/api/check-params/videos.ts b/packages/tests/src/api/check-params/videos.ts index c349ed9fe..a3fd1c0d6 100644 --- a/packages/tests/src/api/check-params/videos.ts +++ b/packages/tests/src/api/check-params/videos.ts @@ -1,22 +1,28 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { join } from 'path' import { omit, randomInt } from '@peertube/peertube-core-utils' -import { HttpStatusCode, PeerTubeProblemDocument, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + HttpStatusCode, + PeerTubeProblemDocument, + VideoCommentPolicy, + VideoCreateResult, + VideoPrivacy +} from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { + PeerTubeServer, cleanupTests, createSingleServer, makeDeleteRequest, makeGetRequest, makePutBodyRequest, makeUploadRequest, - PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' -import { checkBadStartPagination, checkBadCountPagination, checkBadSortPagination } from '@tests/shared/checks.js' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' import { checkUploadVideoParam } from '@tests/shared/videos.js' +import { expect } from 'chai' +import { join } from 'path' describe('Test videos API validator', function () { const path = '/api/v1/videos/' @@ -183,29 +189,30 @@ describe('Test videos API validator', function () { }) describe('When adding a video', function () { - let baseCorrectParams + const baseCorrectParams = { + name: 'my super name', + category: 5, + licence: 1, + language: 'pt', + nsfw: false, + commentsPolicy: VideoCommentPolicy.ENABLED, + downloadEnabled: true, + waitTranscoding: true, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.PUBLIC, + channelId: -1, + originallyPublishedAt: new Date().toISOString() + } + const baseCorrectAttaches = { fixture: buildAbsoluteFixturePath('video_short.webm') } before(function () { // Put in before to have channelId - baseCorrectParams = { - name: 'my super name', - category: 5, - licence: 1, - language: 'pt', - nsfw: false, - commentsEnabled: true, - downloadEnabled: true, - waitTranscoding: true, - description: 'my super description', - support: 'my super support text', - tags: [ 'tag1', 'tag2' ], - privacy: VideoPrivacy.PUBLIC, - channelId, - originallyPublishedAt: new Date().toISOString() - } + baseCorrectParams.channelId = channelId }) function runSuite (mode: 'legacy' | 'resumable') { @@ -260,6 +267,13 @@ describe('Test videos API validator', function () { await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) }) + it('Should fail with bad commentsPolicy', async function () { + const fields = { ...baseCorrectParams, commentsPolicy: 42 as any } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + it('Should fail with a long description', async function () { const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } const attaches = baseCorrectAttaches @@ -331,7 +345,7 @@ describe('Test videos API validator', function () { }) it('Should fail with a bad schedule update (miss updateAt)', async function () { - const fields = { ...baseCorrectParams, scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } } + const fields = { ...baseCorrectParams, scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } as any } const attaches = baseCorrectAttaches await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) @@ -509,7 +523,7 @@ describe('Test videos API validator', function () { licence: 2, language: 'pt', nsfw: false, - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, downloadEnabled: false, description: 'my super description', privacy: VideoPrivacy.PUBLIC, diff --git a/packages/tests/src/api/check-params/views.ts b/packages/tests/src/api/check-params/views.ts index 69f8ffc6e..c965a5479 100644 --- a/packages/tests/src/api/check-params/views.ts +++ b/packages/tests/src/api/check-params/views.ts @@ -10,7 +10,7 @@ import { setDefaultVideoChannel } from '@peertube/peertube-server-commands' -describe('Test videos views', function () { +describe('Test videos views API validators', function () { let servers: PeerTubeServer[] let liveVideoId: string let videoId: string diff --git a/packages/tests/src/api/check-params/watched-words.ts b/packages/tests/src/api/check-params/watched-words.ts new file mode 100644 index 000000000..68fbd2752 --- /dev/null +++ b/packages/tests/src/api/check-params/watched-words.ts @@ -0,0 +1,254 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, UserRole } from '@peertube/peertube-models' +import { + PeerTubeServer, + WatchedWordsCommand, + cleanupTests, + createSingleServer, + makeGetRequest, + setAccessTokensToServers, + setDefaultAccountAvatar +} from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' + +describe('Test watched words API validators', function () { + let server: PeerTubeServer + + let userToken: string + let userToken2: string + let moderatorToken: string + + let command: WatchedWordsCommand + + let accountListId: number + let serverListId: number + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultAccountAvatar([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + userToken2 = await server.users.generateUserAndToken('user2') + moderatorToken = await server.users.generateUserAndToken('moderator', UserRole.MODERATOR) + + command = server.watchedWordsLists + + { + const { watchedWordsList } = await command.createList({ + accountName: 'user1', + token: userToken, + listName: 'default', + words: [ 'word1' ] + }) + accountListId = watchedWordsList.id + } + + { + const { watchedWordsList } = await command.createList({ + listName: 'default', + words: [ 'word1' ] + }) + serverListId = watchedWordsList.id + } + }) + + describe('Account & server watched words', function () { + + describe('When listing watched words', function () { + const paths = [ + '/api/v1/watched-words/accounts/user1/lists', + '/api/v1/watched-words/server/lists' + ] + + it('Should fail with an unauthenticated user', async function () { + for (const path of paths) { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + } + }) + + it('Should fail with the wrong token', async function () { + for (const path of paths) { + await makeGetRequest({ + url: server.url, + token: userToken2, + path, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + }) + + it('Should fail with a bad start/count pagination or incorrect sort', async function () { + for (const path of paths) { + await checkBadStartPagination(server.url, path, userToken) + await checkBadCountPagination(server.url, path, userToken) + await checkBadSortPagination(server.url, path, userToken) + } + }) + }) + + describe('When adding/updating watched words', function () { + const baseParams = () => ([ + { + token: userToken, + accountName: 'user1', + listName: 'list', + words: [ 'word1' ], + listId: accountListId + }, + { + token: moderatorToken, + listName: 'list', + words: [ 'word1' ], + listId: serverListId + } + ]) + + it('Should fail with an unauthenticated user', async function () { + for (const baseParam of baseParams()) { + await command.createList({ ...baseParam, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await command.updateList({ ...baseParam, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should fail with the wrong token', async function () { + for (const baseParam of baseParams()) { + await command.createList({ ...baseParam, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await command.updateList({ ...baseParam, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) + + it('Should fail with an invalid listName', async function () { + for (const baseParam of baseParams()) { + await command.createList({ ...baseParam, listName: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + for (const listName of [ '', 'a'.repeat(500) ]) { + await command.createList({ ...baseParam, listName, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.updateList({ ...baseParam, listName, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + } + }) + + it('Should fail with invalid words', async function () { + const bigArray: string[] = [] + for (let i = 0; i < 550; i++) { + bigArray.push(`word${i}`) + } + + const toTest = [ + [], + bigArray, + [ 'a'.repeat(102) ], + [ '' ], + [ '', 'word' ] + ] + + for (const baseParam of baseParams()) { + await command.createList({ ...baseParam, words: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + for (const words of toTest) { + await command.createList({ ...baseParam, words, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.updateList({ ...baseParam, words, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + } + }) + + it('Should succeed with the correct params', async function () { + for (const baseParam of baseParams()) { + await command.createList(baseParam) + await command.updateList({ ...baseParam, listName: 'updated-list' }) + } + }) + + it('Should succeed to update a list with the same name', async function () { + for (const baseParam of baseParams()) { + await command.updateList({ ...baseParam, listName: 'updated-list' }) + } + }) + + it('Should fail to add a list with an already existing name', async function () { + for (const baseParam of baseParams()) { + await command.createList({ ...baseParam, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.updateList({ ...baseParam, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + }) + + describe('When deleting watched words', function () { + const baseParams = () => ([ + { + token: userToken, + accountName: 'user1', + listId: accountListId + }, + { + token: moderatorToken, + listId: serverListId + } + ]) + + it('Should fail with an unauthenticated user', async function () { + for (const baseParam of baseParams()) { + await command.deleteList({ ...baseParam, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should fail with the wrong token', async function () { + for (const baseParam of baseParams()) { + await command.deleteList({ ...baseParam, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) + + it('Should succeed with the correct params', async function () { + for (const baseParam of baseParams()) { + await command.deleteList(baseParam) + } + }) + }) + }) + + describe('Account specific watched words', function () { + + describe('When listing watched words', function () { + it('Should fail with an unknown account', async function () { + await command.listWordsLists({ accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('When adding/updating watched words', function () { + const baseParams = () => ({ + token: userToken, + accountName: 'user1', + listName: 'list', + words: [ 'word1' ] + }) + + it('Should fail with an unknown account', async function () { + await command.createList({ ...baseParams(), accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('When deleting watched words', function () { + const baseParams = () => ({ + listId: accountListId, + token: userToken, + accountName: 'user1' + }) + + it('Should fail with an unknown account', async function () { + await command.deleteList({ ...baseParams(), accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/live/live.ts b/packages/tests/src/api/live/live.ts index 1bb850a28..fa5862088 100644 --- a/packages/tests/src/api/live/live.ts +++ b/packages/tests/src/api/live/live.ts @@ -9,6 +9,7 @@ import { LiveVideo, LiveVideoCreate, LiveVideoLatencyMode, + VideoCommentPolicy, VideoDetails, VideoPrivacy, VideoState, @@ -88,7 +89,7 @@ describe('Test live', function () { waitTranscoding: false, name: 'my super live', tags: [ 'tag1', 'tag2' ], - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, downloadEnabled: false, saveReplay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC }, diff --git a/packages/tests/src/api/moderation/automatic-tags.ts b/packages/tests/src/api/moderation/automatic-tags.ts new file mode 100644 index 000000000..228a947c3 --- /dev/null +++ b/packages/tests/src/api/moderation/automatic-tags.ts @@ -0,0 +1,489 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' +import { expect } from 'chai' + +describe('Test automatic tags', function () { + let servers: PeerTubeServer[] + let videoUUID: string + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await servers[1].config.enableLive({ allowReplay: false }) + + await doubleFollow(servers[0], servers[1]); + + ({ uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'video' })) + + await waitJobs(servers) + }) + + describe('Automatic tags on comments', function () { + + describe('Built in external link auto tag', function () { + + it('Should not assign external-link automatic tag with no URL inside the comment', async function () { + const tests = [ + 'my super comment', + 'toto.azfazfe', + 'Hello. Hi friends' + ] + + for (const toTest of tests) { + await servers[0].comments.createThread({ videoId: videoUUID, text: toTest }) + await waitJobs(servers) + } + + for (const server of servers) { + const { data } = await server.comments.listForAdmin() + + for (const comment of data) { + expect(comment.automaticTags, `"${comment.text}" has an automatic tag`).to.have.lengthOf(0) + } + } + + await servers[0].comments.deleteAllComments({ videoUUID }) + await waitJobs(servers) + }) + + it('Should not assign external-link automatic tag if the URL is an internal link', async function () { + const tests = [ + `Hi ${servers[0].url}` + ] + + for (const toTest of tests) { + await servers[0].comments.createThread({ videoId: videoUUID, text: toTest }) + await waitJobs(servers) + } + + // Server 1 + { + const { data } = await servers[0].comments.listForAdmin() + + for (const comment of data) { + expect(comment.automaticTags, `"${comment.text}" has an automatic tag`).to.have.lengthOf(0) + } + } + + // Server 2 + { + const { data } = await servers[1].comments.listForAdmin() + + for (const comment of data) { + expect(comment.automaticTags, `"${comment.text}" hasn't an automatic tag`).to.have.lengthOf(1) + expect(comment.automaticTags[0]).to.equal('external-link') + } + } + + await servers[0].comments.deleteAllComments({ videoUUID }) + await waitJobs(servers) + }) + + it('Should assign external-link automatic tag', async function () { + const tests = [ + 'example.com', + 'Hi example.com' + ] + + for (const toTest of tests) { + await servers[0].comments.createThread({ videoId: videoUUID, text: toTest }) + await waitJobs(servers) + } + + for (const server of servers) { + const { data } = await server.comments.listForAdmin() + + for (const comment of data) { + expect(comment.automaticTags).to.have.lengthOf(1) + expect(comment.automaticTags[0]).to.equal('external-link') + } + } + + await servers[0].comments.deleteAllComments({ videoUUID }) + await waitJobs(servers) + }) + }) + + describe('With watched words', function () { + let accountListId: number + + it('Should create watched words list and automatically assign an automatic tag', async function () { + // Account list + { + await servers[0].watchedWordsLists.createList({ listName: 'list 1', words: [ 'word 1', 'word 2' ], accountName: 'root' }) + + const { watchedWordsList } = await servers[0].watchedWordsLists.createList({ + listName: 'list 2', + words: [ 'nemo' ], + accountName: 'root' + }) + accountListId = watchedWordsList.id + } + + // Server list + { + await servers[0].watchedWordsLists.createList({ listName: 'server 2', words: [ 'word 2' ] }) + } + + await servers[0].comments.createThread({ videoId: videoUUID, text: 'hi captain' }) + await servers[0].comments.addReplyToLastThread({ text: 'hi captain nemo' }) + await servers[1].comments.createThread({ videoId: videoUUID, text: 'hi captain nemo word 2 example.com' }) + + await waitJobs(servers) + + // Server comments list must not include account personal watched words + { + const { data } = await servers[0].comments.listForAdmin() + const c = (text: string) => data.find(c => c.text === text) + + expect(c('hi captain').automaticTags).to.have.lengthOf(0) + expect(c('hi captain nemo').automaticTags).to.have.lengthOf(0) + expect(c('hi captain nemo word 2 example.com').automaticTags).to.have.members([ 'server 2', 'external-link' ]) + } + + { + const { data } = await servers[0].comments.listCommentsOnMyVideos() + const c = (text: string) => data.find(c => c.text === text) + + expect(c('hi captain').automaticTags).to.have.lengthOf(0) + expect(c('hi captain nemo').automaticTags).to.have.members([ 'list 2' ]) + expect(c('hi captain nemo word 2 example.com').automaticTags).to.have.members([ 'list 1', 'list 2', 'external-link' ]) + } + }) + + it('Should update watched words list and assign auto tag with new words', async function () { + // No tags + { + await servers[0].comments.createThread({ videoId: videoUUID, text: 'my nautilus' }) + + const { data } = await servers[0].comments.listCommentsOnMyVideos() + expect(data.find(c => c.text === 'my nautilus').automaticTags).to.have.lengthOf(0) + } + + { + await servers[0].watchedWordsLists.updateList({ + accountName: 'root', + listId: accountListId, + words: [ 'nautilus' ], + listName: 'list 3' + }) + + await servers[0].comments.createThread({ videoId: videoUUID, text: 'captain nemo' }) + await servers[0].comments.createThread({ videoId: videoUUID, text: 'my nautilus 2' }) + await servers[0].comments.createThread({ videoId: videoUUID, text: 'word 1' }) + + const { data } = await servers[0].comments.listCommentsOnMyVideos() + // Previous comment still have the same automatic tags + expect(data.find(c => c.text === 'my nautilus').automaticTags).to.have.lengthOf(0) + + expect(data.find(c => c.text === 'captain nemo').automaticTags).to.have.lengthOf(0) + expect(data.find(c => c.text === 'my nautilus 2').automaticTags).to.have.members([ 'list 3' ]) + expect(data.find(c => c.text === 'word 1').automaticTags).to.have.members([ 'list 1' ]) + } + }) + + it('Should delete watched words list and so not assign auto tags anymore', async function () { + await servers[0].watchedWordsLists.deleteList({ accountName: 'root', listId: accountListId }) + + await servers[0].comments.createThread({ videoId: videoUUID, text: 'my nautilus 3' }) + await servers[0].comments.createThread({ videoId: videoUUID, text: 'word 2' }) + + const { data } = await servers[0].comments.listCommentsOnMyVideos() + expect(data.find(c => c.text === 'my nautilus 3').automaticTags).to.have.lengthOf(0) + expect(data.find(c => c.text === 'word 2').automaticTags).to.have.members([ 'list 1' ]) + }) + }) + + describe('Searching comments with specific tags', function () { + + it('Should search in "comments on my videos" comments with specific automatic tags', async function () { + { + const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ autoTagOneOf: [ 'unknown' ] }) + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + + { + for (const autoTagOneOf of [ [ 'list 1' ], [ 'list 1', 'unknown' ] ]) { + const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ autoTagOneOf }) + + expect(total).to.equal(3) + + expect(data.map(c => c.text)).to.have.members([ + 'hi captain nemo word 2 example.com', + 'word 1', + 'word 2' + ]) + } + } + }) + + it('Should search in admin comments with specific automatic tags', async function () { + { + const { total, data } = await servers[0].comments.listForAdmin({ autoTagOneOf: [ 'list 1' ] }) + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + + { + const { total, data } = await servers[0].comments.listForAdmin({ autoTagOneOf: [ 'external-link' ] }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('hi captain nemo word 2 example.com') + } + }) + }) + + }) + + describe('Automatic tags on videos', function () { + + before(async function () { + await servers[0].videos.removeAll() + + await waitJobs(servers) + }) + + describe('Built in external link auto tag', function () { + + it('Should not assign external-link automatic tag with no URL inside the video', async function () { + const tests = [ + 'my super video', + 'toto.azfazfe', + 'Hello. Hi friends' + ] + + for (const toTest of tests) { + await servers[0].videos.upload({ attributes: { name: toTest, description: toTest } }) + await waitJobs(servers) + } + + for (const server of servers) { + const { data } = await server.videos.listAllForAdmin() + + for (const video of data) { + expect(video.automaticTags, `"${video.name}" has an automatic tag`).to.have.lengthOf(0) + } + } + + await servers[0].videos.removeAll() + await waitJobs(servers) + }) + + it('Should not assign external-link automatic tag if the URL is an internal link', async function () { + const tests = [ + `Hi ${servers[0].url}` + ] + + for (const toTest of tests) { + await servers[0].videos.upload({ attributes: { name: toTest, description: toTest } }) + await waitJobs(servers) + } + + // Server 1 + { + const { data } = await servers[0].videos.listAllForAdmin() + + for (const video of data) { + expect(video.automaticTags, `"${video.name}" has an automatic tag`).to.have.lengthOf(0) + } + } + + // Server 2 + { + const { data } = await servers[1].videos.listAllForAdmin() + + for (const video of data) { + expect(video.automaticTags, `"${video.name}" hasn't an automatic tag`).to.have.lengthOf(1) + expect(video.automaticTags[0]).to.equal('external-link') + } + } + + await servers[0].videos.removeAll() + await waitJobs(servers) + }) + + it('Should assign external-link automatic tag', async function () { + const tests = [ + 'example.com', + 'Hi example.com' + ] + + for (const toTest of tests) { + await servers[0].videos.upload({ attributes: { name: toTest, description: toTest } }) + await waitJobs(servers) + } + + for (const server of servers) { + const { data } = await server.videos.listAllForAdmin() + + for (const video of data) { + expect(video.automaticTags).to.have.lengthOf(1) + expect(video.automaticTags[0]).to.equal('external-link') + } + } + + await servers[0].videos.removeAll() + await waitJobs(servers) + }) + }) + + describe('With watched words', function () { + let serverListId: number + let liveUUID: string + + it('Should create watched words list and automatically assign an automatic tag', async function () { + // Server list + { + await servers[0].watchedWordsLists.createList({ + listName: 'donald list', + words: [ 'riri', 'fifi', 'loulou' ] + }) + + const { watchedWordsList } = await servers[0].watchedWordsLists.createList({ + listName: 'mickey list', + words: [ 'dingo', 'pluto' ] + }) + serverListId = watchedWordsList.id + } + + // Account list + { + await servers[0].watchedWordsLists.createList({ listName: 'picsou list', words: [ 'goldie' ], accountName: 'root' }) + } + + await servers[0].videos.upload({ attributes: { name: 'my dear goldie', description: 'hi riri and fifi' } }) + await servers[0].videoImports.importVideo({ + attributes: { + targetUrl: FIXTURE_URLS.goodVideo, + channelId: servers[0].store.channel.id, + name: 'import video', + description: 'pluto dog' + } + }) + const { uuid } = await servers[1].live.create({ + fields: { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + name: 'live loulou', + description: 'dingo and minnie' + } + }) + liveUUID = uuid + + await waitJobs(servers) + + // Server videos list must not include account personal watched words + { + const { data } = await servers[0].videos.listAllForAdmin() + const v = (name: string) => data.find(c => c.name === name) + + expect(v('my dear goldie').automaticTags).to.have.members([ 'donald list' ]) + expect(v('import video').automaticTags).to.have.members([ 'mickey list' ]) + expect(v('live loulou').automaticTags).to.have.members([ 'donald list', 'mickey list' ]) + } + + { + const { data } = await servers[0].videos.listMyVideos() + + for (const video of data) { + expect(video.automaticTags).to.not.exist + } + } + }) + + it('Should update watched words list and assign auto tag on update', async function () { + const { uuid } = await servers[0].videos.quickUpload({ name: 'hi minnie' }) + + { + const { data } = await servers[0].videos.listAllForAdmin() + expect(data.find(v => v.name === 'hi minnie').automaticTags).to.have.lengthOf(0) + } + + { + await servers[0].watchedWordsLists.updateList({ + listId: serverListId, + words: [ 'Minnie' ], + listName: 'mickey list v2' + }) + + await servers[0].videos.update({ id: uuid, attributes: { name: 'hi minnie v2' } }) + + const { data } = await servers[0].videos.listAllForAdmin() + expect(data.find(v => v.name === 'hi minnie v2').automaticTags).to.have.members([ 'mickey list v2' ]) + } + }) + + it('Should not update remote video if name/description has not changed', async function () { + await servers[1].videos.update({ + id: liveUUID, + attributes: { + channelId: servers[0].store.channel.id, + tags: [ 'super tag' ] + } + }) + + await waitJobs(servers) + + const { data } = await servers[0].videos.listAllForAdmin() + expect(data.find(v => v.name === 'live loulou').automaticTags).to.have.members([ 'donald list', 'mickey list' ]) + }) + + it('Should update remote video if name/description has changed', async function () { + await servers[1].videos.update({ + id: liveUUID, + attributes: { name: 'live loulou v2' } + }) + + await waitJobs(servers) + + const { data } = await servers[0].videos.listAllForAdmin() + expect(data.find(v => v.name === 'live loulou v2').automaticTags).to.have.members([ 'donald list', 'mickey list v2' ]) + }) + }) + + describe('Searching videos with specific tags', function () { + + it('Should search in admin videos with specific automatic tags', async function () { + { + const { total, data } = await servers[0].videos.listAllForAdmin({ autoTagOneOf: [ 'picsou list' ] }) + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + + { + const { total, data } = await servers[0].videos.listAllForAdmin({ autoTagOneOf: [ 'mickey list v2' ] }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + expect(data.map(d => d.name)).to.have.members([ 'hi minnie v2', 'live loulou v2' ]) + } + }) + }) + + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/moderation/comment-approval.ts b/packages/tests/src/api/moderation/comment-approval.ts new file mode 100644 index 000000000..c9c02130e --- /dev/null +++ b/packages/tests/src/api/moderation/comment-approval.ts @@ -0,0 +1,552 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + ActivityApproveReply, + ActivityPubOrderedCollection, + HttpStatusCode, + UserRole, + VideoCommentObject, + VideoCommentPolicy, + VideoCommentPolicyType, + VideoPrivacy +} from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, createMultipleServers, + doubleFollow, + makeActivityPubGetRequest, makeActivityPubRawRequest, setAccessTokensToServers, + setDefaultAccountAvatar, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { expect } from 'chai' + +describe('Test comments approval', function () { + let servers: PeerTubeServer[] + let userToken: string + let anotherUserToken: string + let moderatorToken: string + + async function createVideo (commentsPolicy: VideoCommentPolicyType) { + const { uuid } = await servers[0].videos.upload({ + token: userToken, + attributes: { + name: 'review policy: ' + commentsPolicy, + privacy: VideoPrivacy.PUBLIC, + commentsPolicy + } + }) + + await waitJobs(servers) + + return uuid + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + await setAccessTokensToServers(servers) + await setDefaultAccountAvatar(servers) + + await doubleFollow(servers[0], servers[1]) + await doubleFollow(servers[0], servers[2]) + await doubleFollow(servers[1], servers[2]) + + userToken = await servers[0].users.generateUserAndToken('user1') + anotherUserToken = await servers[0].users.generateUserAndToken('user2') + moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR) + }) + + describe('On video with comments requiring approval', function () { + let videoId: string + + before(async function () { + this.timeout(30000) + + videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL) + }) + + it('Should create a local and remote comment that require approval', async function () { + this.timeout(30000) + + await servers[0].comments.createThread({ text: 'local', videoId, token: anotherUserToken }) + await servers[1].comments.createThread({ text: 'remote', videoId }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + expect(data).to.have.lengthOf(2) + + for (const c of data) { + expect(c.heldForReview).to.be.true + } + }) + + it('Should display comments depending on the user', async function () { + // Owner see the comments + { + const { data } = await servers[0].comments.listThreads({ videoId, token: userToken }) + expect(data).to.have.lengthOf(2) + + for (const c of data) { + expect(c.heldForReview).to.be.true + } + } + + // Anonymous doesn't see the comments + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId }) + expect(data).to.have.lengthOf(0) + } + + // Owner of the comment can see it + { + const { data } = await servers[1].comments.listThreads({ videoId, token: servers[1].accessToken }) + expect(data).to.have.lengthOf(1) + expect(data[0].heldForReview).to.be.true + expect(data[0].text).to.equal('remote') + } + }) + + it('Should create a local and remote reply and require approval', async function () { + await servers[0].comments.addReplyToLastThread({ text: 'local reply', token: anotherUserToken }) + await servers[1].comments.addReplyToLastThread({ text: 'remote reply' }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + expect(data).to.have.lengthOf(4) + + for (const c of data) { + expect(c.heldForReview).to.be.true + } + }) + + it('Should approve a thread comment', async function () { + { + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + const commentId = data.find(c => c.text === 'remote').id + await servers[0].comments.approve({ commentId, videoId, token: userToken }) + await waitJobs(servers) + } + + // Owner and moderators + for (const token of [ userToken, moderatorToken ]) { + const { data: threads } = await servers[0].comments.listThreads({ videoId, token }) + expect(threads).to.have.lengthOf(2) + + for (const c of threads) { + if (c.text === 'remote') expect(c.heldForReview).to.be.false + else expect(c.heldForReview).to.be.true + + const thread = await servers[0].comments.getThread({ videoId, threadId: c.id, token }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.heldForReview).to.equal(true) + } + } + + // Anonymous + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId }) + expect(data).to.have.lengthOf(1) + expect(data[0].heldForReview).to.be.false + expect(data[0].text).to.equal('remote') + + const thread = await server.comments.getThread({ videoId, threadId: data[0].id }) + expect(thread.children).to.have.lengthOf(0) + } + + // Owner of the comment can see it + { + const { data } = await servers[1].comments.listThreads({ videoId, token: servers[1].accessToken }) + expect(data).to.have.lengthOf(1) + expect(data[0].heldForReview).to.be.false + expect(data[0].text).to.equal('remote') + + const thread = await servers[1].comments.getThread({ videoId, threadId: data[0].id, token: servers[1].accessToken }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.heldForReview).to.equal(true) + } + }) + + it('Should approve a reply comment', async function () { + { + const commentId = await servers[0].comments.findCommentId({ videoId, text: 'remote reply' }) + await servers[0].comments.approve({ commentId, videoId, token: userToken }) + await waitJobs(servers) + + // Owner + { + const { data } = await servers[0].comments.listThreads({ videoId, token: userToken }) + expect(data.filter(c => c.heldForReview)).to.have.lengthOf(1) + + const thread = await servers[0].comments.getThreadOf({ videoId, text: 'remote', token: userToken }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.text).to.equal('remote reply') + expect(thread.children[0].comment.heldForReview).to.be.false + } + + // Other users + for (const server of servers) { + const thread = await server.comments.getThreadOf({ videoId, text: 'remote' }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.text).to.equal('remote reply') + expect(thread.children[0].comment.heldForReview).to.be.false + } + } + }) + + it('Should list and filter on comments awaiting approval', async function () { + { + const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken }) + expect(total).to.equal(4) + expect(data).to.have.lengthOf(4) + } + + { + const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken, isHeldForReview: true }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + expect(data.filter(c => c.heldForReview)).to.have.lengthOf(2) + } + + { + const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken, isHeldForReview: false }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + expect(data.filter(c => !c.heldForReview)).to.have.lengthOf(2) + } + }) + + it('Should approve a reply of a non approved reply', async function () { + const threadId = await servers[0].comments.findCommentId({ videoId, text: 'local' }) + + const { id: replyId } = await servers[0].comments.addReply({ + videoId, + toCommentId: await servers[0].comments.findCommentId({ videoId, text: 'local reply' }), + text: 'local reply 2', + token: anotherUserToken + }) + + await servers[0].comments.approve({ commentId: replyId, videoId, token: userToken }) + await servers[0].comments.approve({ commentId: threadId, videoId, token: userToken }) + await waitJobs(servers) + + // Owner + { + const { data } = await servers[0].comments.listThreads({ videoId, token: userToken }) + expect(data.filter(c => c.heldForReview)).to.have.lengthOf(0) + + const thread = await servers[0].comments.getThreadOf({ videoId, text: 'local', token: userToken }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.text).to.equal('local reply') + expect(thread.children[0].comment.heldForReview).to.be.true + + expect(thread.children[0].children).to.have.lengthOf(1) + expect(thread.children[0].children[0].comment.text).to.equal('local reply 2') + expect(thread.children[0].children[0].comment.heldForReview).to.be.false + } + + // Other users + for (const server of servers) { + const thread = await server.comments.getThreadOf({ videoId, text: 'local' }) + expect(thread.children).to.have.lengthOf(0) + } + }) + + it('Should have appropriate ActivityPub representation', async function () { + const localNonApprovedId = await servers[0].comments.findCommentId({ text: 'local reply', videoId }) + const localApprovedId = await servers[0].comments.findCommentId({ text: 'local', videoId }) + const remoteApprovedId = await servers[0].comments.findCommentId({ text: 'remote', videoId }) + + { + for (const page of [ 1, 2 ]) { + const res = await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${videoId}/comments?page=${page}`) + const { totalItems, orderedItems } = res.body as ActivityPubOrderedCollection<string> + + expect(totalItems).to.equal(4) + expect(orderedItems.some(url => url === `${servers[0].url}/videos/watch/${videoId}/comments/${localNonApprovedId}`)).to.be.false + } + } + + { + await makeActivityPubGetRequest( + servers[0].url, + `/videos/watch/${videoId}/comments/${localNonApprovedId}`, + HttpStatusCode.NOT_FOUND_404 + ) + + await makeActivityPubGetRequest( + servers[0].url, + `/videos/watch/${videoId}/comments/${localNonApprovedId}/approve-reply`, + HttpStatusCode.NOT_FOUND_404 + ) + } + + const toTest = [ { server: servers[0], commentId: localApprovedId }, { server: servers[1], commentId: remoteApprovedId } ] + for (const { server, commentId } of toTest) { + const res = await makeActivityPubGetRequest(server.url, `/videos/watch/${videoId}/comments/${commentId}`) + const { replyApproval } = res.body as VideoCommentObject + + expectStartWith(replyApproval, `${servers[0].url}/videos/watch/${videoId}/comments/`) + const res2 = await makeActivityPubRawRequest(replyApproval, HttpStatusCode.OK_200) + + const object = res2.body as ActivityApproveReply + expect(object.type).to.equal('ApproveReply') + } + }) + + it('Should remove an approved/non-approved comments', async function () { + this.timeout(60000) + + { + const commentId = await servers[1].comments.findCommentId({ videoId, text: 'local' }) + await servers[1].comments.addReply({ videoId, toCommentId: commentId, text: 'remote reply on local' }) + await waitJobs(servers) + } + + for (const text of [ 'remote', 'local reply', 'remote reply on local' ]) { + const commentId = await servers[0].comments.findCommentId({ videoId, text }) + await servers[0].comments.delete({ videoId, commentId }) + } + + await waitJobs(servers) + + // Owner + { + const { data } = await servers[0].comments.listThreads({ videoId, token: userToken, sort: '-createdAt' }) + expect(data).to.have.lengthOf(2) + + { + const remote = data[0] + expect(remote.isDeleted).to.be.true + + const thread = await servers[0].comments.getThread({ videoId, token: userToken, threadId: remote.id }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.text).to.equal('remote reply') + } + + { + const local = data[1] + expect(local.isDeleted).to.be.false + + const thread = await servers[0].comments.getThread({ videoId, token: userToken, threadId: local.id }) + expect(thread.children).to.have.lengthOf(2) + + { + const localReply = thread.children[0] + expect(localReply.comment.deletedAt).to.exist + expect(localReply.comment.heldForReview).to.be.true + expect(localReply.children).to.have.lengthOf(1) + + expect(localReply.children).to.have.lengthOf(1) + expect(localReply.children[0].comment.text).to.equal('local reply 2') + expect(localReply.children[0].comment.heldForReview).to.be.false + expect(localReply.children[0].children).to.have.lengthOf(0) + } + + { + expect(thread.children[1].comment.deletedAt).to.exist + } + } + } + + // Other users + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId, sort: '-createdAt' }) + expect(data).to.have.lengthOf(2) + + { + const remote = data[0] + expect(remote.isDeleted).to.be.true + + const thread = await server.comments.getThread({ videoId, threadId: remote.id }) + expect(thread.children).to.have.lengthOf(1) + expect(thread.children[0].comment.text).to.equal('remote reply') + } + + { + const local = data[1] + expect(local.isDeleted).to.be.false + + const thread = await server.comments.getThread({ videoId, threadId: local.id }) + // Anonymous users cannot see the thread because the delete comment was held for review + expect(thread.children).to.have.lengthOf(0) + } + } + }) + + it('Should not require review for video uploader, admins and moderators', async function () { + for (const token of [ userToken, moderatorToken, servers[0].accessToken ]) { + await servers[0].comments.createThread({ videoId, text: 'right', token }) + } + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId, sort: '-createdAt' }) + expect(data.filter(c => c.text === 'right')).to.have.lengthOf(3) + } + }) + }) + + describe('On video with comments with some tags requiring approval', function () { + let videoId: string + + before(async function () { + this.timeout(30000) + + videoId = await createVideo(VideoCommentPolicy.ENABLED) + }) + + it('Should only have built-in auto tag policies and no policies set', async function () { + const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken }) + expect(review).to.have.lengthOf(0) + + const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken }) + expect(available.map(a => a.name)).to.deep.equal([ 'external-link' ]) + }) + + it('Should add watched words and so available tag policies', async function () { + await servers[0].watchedWordsLists.createList({ + token: userToken, + listName: 'forbidden-list', + words: [ 'forbidden' ], + accountName: 'user1' + }) + + await servers[0].watchedWordsLists.createList({ + token: userToken, + listName: 'allowed-list', + words: [ 'allowed' ], + accountName: 'user1' + }) + + const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken }) + expect(review).to.have.lengthOf(0) + + const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken }) + expect(available.map(a => a.name)).to.have.deep.members([ 'external-link', 'forbidden-list', 'allowed-list' ]) + }) + + it('Should update policies', async function () { + await servers[0].autoTags.updateCommentPolicies({ + accountName: 'user1', + review: [ 'external-link', 'forbidden-list' ], + token: userToken + }) + + const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken }) + expect(review).to.have.deep.members([ 'external-link', 'forbidden-list' ]) + }) + + it('Should publish a comment without approval', async function () { + const threadText = '1 - framasoft and allowed' + const replyText = '1 - frama and allowed' + + await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: threadText }) + await waitJobs(servers) + + const commentId = await servers[1].comments.findCommentId({ videoId, text: threadText }) + await servers[1].comments.addReply({ text: replyText, videoId, toCommentId: commentId }) + + await waitJobs(servers) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + const t = data.find(c => c.text === threadText) + const r = data.find(c => c.text === replyText) + + expect(t.automaticTags).to.have.members([ 'allowed-list' ]) + expect(t.heldForReview).to.be.false + + expect(r.automaticTags).to.have.members([ 'allowed-list' ]) + expect(r.heldForReview).to.be.false + }) + + it('Should publish a comment with approval', async function () { + const threadText = '2 - framasoft.org and allowed' + const replyText = '2 - https://framasoft.org and forbidden' + + await servers[1].comments.createThread({ videoId, text: threadText }) + await waitJobs(servers) + + const commentId = await servers[0].comments.findCommentId({ videoId, text: threadText }) + await servers[0].comments.addReply({ token: anotherUserToken, text: replyText, videoId, toCommentId: commentId }) + + await waitJobs(servers) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + const t = data.find(c => c.text === threadText) + const r = data.find(c => c.text === replyText) + + expect(t.automaticTags).to.have.members([ 'external-link', 'allowed-list' ]) + expect(t.heldForReview).to.be.true + + expect(r.automaticTags).to.have.members([ 'external-link', 'forbidden-list' ]) + expect(r.heldForReview).to.be.true + }) + + it('Should update policies and not update previously tags set', async function () { + await servers[0].autoTags.updateCommentPolicies({ accountName: 'user1', review: [ 'forbidden-list' ], token: userToken }) + + const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken }) + expect(review).to.have.deep.members([ 'forbidden-list' ]) + + const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken }) + expect(available.map(a => a.name)).to.have.deep.members([ 'external-link', 'forbidden-list', 'allowed-list' ]) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken }) + expect(data.filter(c => c.heldForReview)).to.have.lengthOf(2) + }) + + it('Should publish a comment with and without approval base on the new policies', async function () { + const threadText = '3 - framasoft.org and allowed' + const replyText = '3 - forbidden' + + await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: threadText }) + await servers[0].comments.addReplyToLastThread({ token: anotherUserToken, text: replyText }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + const t = data.find(c => c.text === threadText) + const r = data.find(c => c.text === replyText) + + expect(t.automaticTags).to.have.members([ 'external-link', 'allowed-list' ]) + expect(t.heldForReview).to.be.false + + expect(r.automaticTags).to.have.members([ 'forbidden-list' ]) + expect(r.heldForReview).to.be.true + }) + + it('Should not require approval for a moderator but it should have the tag set', async function () { + await servers[0].comments.createThread({ token: moderatorToken, videoId, text: 'forbidden' }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken }) + const t = data.find(c => c.text === 'forbidden') + + expect(t.automaticTags).to.have.members([ 'forbidden-list' ]) + expect(t.heldForReview).to.be.false + }) + + it('Should not have threads waiting for approval before approbation for anonymous users on server 1 and 3', async function () { + for (const server of [ servers[0], servers[2] ]) { + const { data } = await server.comments.listThreads({ videoId }) + expect(data).to.have.lengthOf(3) + + expect(data.some(c => c.text === '2 - framasoft.org and allowed')).to.be.false + } + }) + + it('Should see threads waiting for approval before approbation for anonymous users on server 2', async function () { + const { data } = await servers[1].comments.listThreads({ videoId }) + expect(data).to.have.lengthOf(4) + + expect(data.some(c => c.text === '2 - framasoft.org and allowed')).to.be.true + expect(data.some(c => c.text === '2 - https://framasoft.org and forbidden')).to.be.false + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/moderation/index.ts b/packages/tests/src/api/moderation/index.ts index e3794d01e..7ce9c4a3e 100644 --- a/packages/tests/src/api/moderation/index.ts +++ b/packages/tests/src/api/moderation/index.ts @@ -1,4 +1,6 @@ export * from './abuses.js' +export * from './automatic-tags.js' export * from './blocklist-notification.js' export * from './blocklist.js' export * from './video-blacklist.js' +export * from './watched-words.js' diff --git a/packages/tests/src/api/moderation/watched-words.ts b/packages/tests/src/api/moderation/watched-words.ts new file mode 100644 index 000000000..3665529ff --- /dev/null +++ b/packages/tests/src/api/moderation/watched-words.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar +} from '@peertube/peertube-server-commands' +import { expect } from 'chai' + +describe('Test watched words', function () { + let server: PeerTubeServer + let userToken: string + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultAccountAvatar([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + }) + + function runTests (mode: 'server' | 'account') { + let listId: number + let accountName: string + let token: string + + before(() => { + accountName = mode === 'server' + ? undefined + : 'user1' + + token = mode === 'server' + ? server.accessToken + : userToken + }) + + it('Should list empty watched words', async function () { + const { data, total } = await server.watchedWordsLists.listWordsLists({ token, accountName }) + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should add watched words lists', async function () { + { + const { watchedWordsList } = await server.watchedWordsLists.createList({ + token, + listName: 'list user one', + words: [ 'word1' ], + accountName + }) + + listId = watchedWordsList.id + } + + { + await server.watchedWordsLists.createList({ + token, + listName: 'list user two', + words: [ 'word2', 'word3' ], + accountName + }) + } + + if (mode === 'account') { + await server.watchedWordsLists.createList({ + listName: 'list one', + words: [ 'word4', 'word5' ], + accountName: 'root' + }) + } + }) + + it('Should list watched words', async function () { + if (mode === 'account') { + const { data, total } = await server.watchedWordsLists.listWordsLists({ accountName: 'root' }) + + expect(total).to.equal(1) + + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.exist + expect(data[0].createdAt).to.exist + expect(data[0].updatedAt).to.exist + expect(data[0].listName).to.equal('list one') + expect(data[0].words).to.deep.equal([ 'word4', 'word5' ]) + } + + // With sort, start, count + { + const { data, total } = await server.watchedWordsLists.listWordsLists({ + token, + accountName, + sort: 'createdAt' + }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + expect(data[0].listName).to.equal('list user one') + expect(data[0].words).to.deep.equal([ 'word1' ]) + + expect(data[1].listName).to.equal('list user two') + expect(data[1].words).to.deep.equal([ 'word2', 'word3' ]) + } + + { + const { data, total } = await server.watchedWordsLists.listWordsLists({ + token, + accountName, + sort: '-listName' + }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + expect(data[0].listName).to.equal('list user two') + expect(data[1].listName).to.equal('list user one') + } + + { + const { data, total } = await server.watchedWordsLists.listWordsLists({ + accountName, + token, + sort: '-listName', + start: 1, + count: 1 + }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(1) + + expect(data[0].listName).to.equal('list user one') + } + }) + + it('Should update watched words lists', async function () { + await server.watchedWordsLists.updateList({ + listId, + token, + accountName, + words: [ 'updated-word1', 'updated-word2' ] + }) + + await server.watchedWordsLists.updateList({ + listId, + token, + accountName, + listName: 'updated-list' + }) + + const { data } = await server.watchedWordsLists.listWordsLists({ token, accountName }) + const list = data.find(l => l.id === listId) + + expect(list.listName).to.equal('updated-list') + expect(list.words).to.deep.equal([ 'updated-word1', 'updated-word2' ]) + }) + + it('Should delete watched words lists', async function () { + await server.watchedWordsLists.deleteList({ + listId, + token, + accountName + }) + + const { total, data } = await server.watchedWordsLists.listWordsLists({ token, accountName }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const list = data.find(l => l.id === listId) + expect(list).to.not.exist + }) + } + + describe('Managing account watched words', function () { + runTests('account') + }) + + describe('Managing instance watched words', function () { + runTests('server') + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/notifications/comments-notifications.ts b/packages/tests/src/api/notifications/comments-notifications.ts index 6c29436a6..5defc3234 100644 --- a/packages/tests/src/api/notifications/comments-notifications.ts +++ b/packages/tests/src/api/notifications/comments-notifications.ts @@ -1,14 +1,15 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { UserNotification } from '@peertube/peertube-models' -import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' +import { UserNotification, UserNotificationType, VideoCommentPolicy } from '@peertube/peertube-models' +import { PeerTubeServer, cleanupTests, setDefaultAccountAvatar, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' -import { prepareNotificationsTest, CheckerBaseParams, checkNewCommentOnMyVideo, checkCommentMention } from '@tests/shared/notifications.js' +import { CheckerBaseParams, checkCommentMention, checkNewCommentOnMyVideo, prepareNotificationsTest } from '@tests/shared/notifications.js' +import { expect } from 'chai' describe('Test comments notifications', function () { let servers: PeerTubeServer[] = [] let userToken: string + let userToken2: string let userNotifications: UserNotification[] = [] let emails: object[] = [] @@ -24,6 +25,9 @@ describe('Test comments notifications', function () { userToken = res.userAccessToken servers = res.servers userNotifications = res.userNotifications + + userToken2 = await servers[0].users.generateUserAndToken('user2') + await setDefaultAccountAvatar(servers[0], userToken2) }) describe('Comment on my video notifications', function () { @@ -94,11 +98,9 @@ describe('Test comments notifications', function () { this.timeout(60000) const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) - await waitJobs(servers) await servers[1].comments.createThread({ videoId: uuid, text: 'comment' }) - await waitJobs(servers) const { data } = await servers[0].comments.listThreads({ videoId: uuid }) @@ -147,6 +149,45 @@ describe('Test comments notifications', function () { await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId, commentId, checkType: 'presence' }) }) + it('Should send a new comment notification of a comment that requires approval', async function () { + this.timeout(60000) + + const { id: videoId, uuid, shortUUID } = await servers[0].videos.upload({ + token: userToken, + attributes: { name: 'super video', commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL } + }) + await waitJobs(servers) + + let localCommentId: number + { + const created = await servers[0].comments.createThread({ videoId: uuid, text: 'local approval', token: userToken2 }) + const commentId = localCommentId = created.id + + await waitJobs(servers) + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'presence', approval: true }) + } + + { + await servers[1].comments.createThread({ videoId: uuid, text: 'remote approval' }) + await waitJobs(servers) + + const commentId = await servers[0].comments.findCommentId({ token: userToken, videoId, text: 'remote approval' }) + + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'presence', approval: true }) + } + + // It should not re-notify on approval + { + await servers[0].comments.approve({ token: userToken, commentId: localCommentId, videoId: shortUUID }) + await waitJobs(servers) + + const notifications = baseParams.socketNotifications + .filter(n => n.type === UserNotificationType.NEW_COMMENT_ON_MY_VIDEO && n.comment?.video?.shortUUID === shortUUID) + + expect(notifications).to.have.lengthOf(2) + } + }) + it('Should convert markdown in comment to html', async function () { this.timeout(60000) @@ -276,6 +317,64 @@ describe('Test comments notifications', function () { await checkCommentMention({ ...baseParams, shortUUID, commentId, threadId, byAccountDisplayName, checkType: 'presence' }) }) + it('Should not send a new mention notification before approval', async function () { + this.timeout(60000) + + const { id: videoId, uuid, shortUUID } = await servers[0].videos.upload({ + attributes: { name: 'super video', commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL } + }) + await waitJobs(servers) + + const localText = '@user_1 local approval' + { + const { id: threadId } = await servers[0].comments.createThread({ videoId: uuid, text: localText, token: userToken2 }) + await waitJobs(servers) + + await checkCommentMention({ + ...baseParams, + shortUUID, + threadId, + commentId: threadId, + byAccountDisplayName: 'user2', + checkType: 'absence' + }) + } + + const remoteText = `@user_1@${servers[0].host} remote approval` + { + await servers[1].comments.createThread({ videoId: uuid, text: remoteText }) + await waitJobs(servers) + + const threadId = await servers[0].comments.findCommentId({ token: userToken, videoId, text: remoteText }) + const byAccountDisplayName = 'super root 2 name' + await checkCommentMention({ ...baseParams, shortUUID, threadId, commentId: threadId, byAccountDisplayName, checkType: 'absence' }) + } + + // It should notify on approval + { + const toTest = [ + { text: localText, byAccountDisplayName: 'user2' }, + { text: remoteText, byAccountDisplayName: 'super root 2 name' } + ] + + for (const { text, byAccountDisplayName } of toTest) { + const localCommentId = await servers[0].comments.findCommentId({ token: userToken, videoId, text }) + + await servers[0].comments.approve({ commentId: localCommentId, videoId: shortUUID }) + await waitJobs(servers) + + await checkCommentMention({ + ...baseParams, + shortUUID, + threadId: localCommentId, + commentId: localCommentId, + byAccountDisplayName, + checkType: 'presence' + }) + } + } + }) + it('Should convert markdown in comment to html', async function () { this.timeout(60000) diff --git a/packages/tests/src/api/server/config-defaults.ts b/packages/tests/src/api/server/config-defaults.ts index 35ac89fdf..42b517ba2 100644 --- a/packages/tests/src/api/server/config-defaults.ts +++ b/packages/tests/src/api/server/config-defaults.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { expect } from 'chai' -import { VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { VideoCommentPolicy, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' import { cleanupTests, createSingleServer, @@ -31,7 +31,7 @@ describe('Test config defaults', function () { const overrideConfig = { defaults: { publish: { - comments_enabled: false, + comments_policy: 2, download_enabled: false, privacy: VideoPrivacy.INTERNAL, licence: 4 @@ -46,13 +46,14 @@ describe('Test config defaults', function () { const attributes = { name: 'video', downloadEnabled: undefined, - commentsEnabled: undefined, + commentsPolicy: undefined, licence: undefined, privacy: VideoPrivacy.PUBLIC // Privacy is mandatory for server } function checkVideo (video: VideoDetails) { expect(video.downloadEnabled).to.be.false + expect(video.commentsPolicy.id).to.equal(VideoCommentPolicy.DISABLED) expect(video.commentsEnabled).to.be.false expect(video.licence.id).to.equal(4) } @@ -67,6 +68,7 @@ describe('Test config defaults', function () { const config = await server.config.getConfig() expect(config.defaults.publish.commentsEnabled).to.be.false + expect(config.defaults.publish.commentsPolicy).to.equal(VideoCommentPolicy.DISABLED) expect(config.defaults.publish.downloadEnabled).to.be.false expect(config.defaults.publish.licence).to.equal(4) expect(config.defaults.publish.privacy).to.equal(VideoPrivacy.INTERNAL) diff --git a/packages/tests/src/api/server/follows.ts b/packages/tests/src/api/server/follows.ts index 448f28d62..264f408c2 100644 --- a/packages/tests/src/api/server/follows.ts +++ b/packages/tests/src/api/server/follows.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { Video, VideoPrivacy } from '@peertube/peertube-models' -import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' +import { Video, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models' +import { PeerTubeServer, cleanupTests, createMultipleServers, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' import { expectAccountFollows, expectChannelsFollows } from '@tests/shared/actors.js' import { testCaptionFile } from '@tests/shared/captions.js' import { dateIsValid } from '@tests/shared/checks.js' import { completeVideoCheck } from '@tests/shared/videos.js' +import { expect } from 'chai' describe('Test follows', function () { @@ -463,7 +463,7 @@ describe('Test follows', function () { name: 'root', host: servers[2].host }, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, duration: 5, tags: [ 'tag1', 'tag2', 'tag3' ], diff --git a/packages/tests/src/api/server/handle-down.ts b/packages/tests/src/api/server/handle-down.ts index 474048037..f371a474d 100644 --- a/packages/tests/src/api/server/handle-down.ts +++ b/packages/tests/src/api/server/handle-down.ts @@ -1,19 +1,19 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { wait } from '@peertube/peertube-core-utils' -import { HttpStatusCode, JobState, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, JobState, VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' import { - cleanupTests, CommentsCommand, + PeerTubeServer, + cleanupTests, createMultipleServers, killallServers, - PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' import { SQLCommand } from '@tests/shared/sql-command.js' import { completeVideoCheck } from '@tests/shared/videos.js' +import { expect } from 'chai' describe('Test handle downs', function () { let servers: PeerTubeServer[] = [] @@ -59,7 +59,7 @@ describe('Test handle downs', function () { duration: 10, tags: [ 'tag1p1', 'tag2p1' ], privacy: VideoPrivacy.PUBLIC, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, channel: { name: 'root_channel', diff --git a/packages/tests/src/api/users/user-export.ts b/packages/tests/src/api/users/user-export.ts index 06013d863..513ef6c80 100644 --- a/packages/tests/src/api/users/user-export.ts +++ b/packages/tests/src/api/users/user-export.ts @@ -4,6 +4,7 @@ import { wait } from '@peertube/peertube-core-utils' import { AccountExportJSON, ActivityPubActor, ActivityPubOrderedCollection, + AutoTagPoliciesJSON, BlocklistExportJSON, ChannelExportJSON, CommentsExportJSON, @@ -24,7 +25,8 @@ import { VideoPlaylistPrivacy, VideoPlaylistsExportJSON, VideoPlaylistType, - VideoPrivacy + VideoPrivacy, + WatchedWordsListsJSON } from '@peertube/peertube-models' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { @@ -172,7 +174,9 @@ function runTest (withObjectStorage: boolean) { 'peertube/likes.json', 'peertube/user-settings.json', 'peertube/video-playlists.json', - 'peertube/videos.json' + 'peertube/videos.json', + 'peertube/watched-words-lists.json', + 'peertube/automatic-tag-policies.json' ] for (const file of files) { @@ -574,6 +578,28 @@ function runTest (withObjectStorage: boolean) { expect(secondaryChannelVideo.channel.name).to.equal('noah_second_channel') } } + + { + const json = await parseZIPJSONFile<WatchedWordsListsJSON>(zip, 'peertube/watched-words-lists.json') + + expect(json.watchedWordLists).to.have.lengthOf(2) + + expect(json.watchedWordLists[0].createdAt).to.exist + expect(json.watchedWordLists[0].updatedAt).to.exist + expect(json.watchedWordLists[0].listName).to.equal('forbidden-list') + expect(json.watchedWordLists[0].words).to.have.members([ 'forbidden' ]) + + expect(json.watchedWordLists[1].createdAt).to.exist + expect(json.watchedWordLists[1].updatedAt).to.exist + expect(json.watchedWordLists[1].listName).to.equal('allowed-list') + expect(json.watchedWordLists[1].words).to.have.members([ 'allowed', 'allowed2' ]) + } + + { + const json = await parseZIPJSONFile<AutoTagPoliciesJSON>(zip, 'peertube/automatic-tag-policies.json') + expect(json.reviewComments).to.have.lengthOf(2) + expect(json.reviewComments.map(r => r.name)).to.have.members([ 'external-link', 'forbidden-list' ]) + } }) it('Should have a valid export of static files', async function () { diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts index 7eb93d6d7..b08323b99 100644 --- a/packages/tests/src/api/users/user-import.ts +++ b/packages/tests/src/api/users/user-import.ts @@ -5,6 +5,7 @@ import { LiveVideoLatencyMode, UserImportState, UserNotificationSettingValue, + VideoCommentPolicy, VideoCreateResult, VideoPlaylistPrivacy, VideoPlaylistType, @@ -336,6 +337,25 @@ function runTest (withObjectStorage: boolean) { expect(data[1].url).to.equal(server.url + '/videos/watch/' + noahVideo.uuid) }) + it('Should have correctly imported watched words lists', async function () { + const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' }) + + expect(data).to.have.lengthOf(2) + + expect(data[0].listName).to.equal('allowed-list') + expect(data[0].words).to.have.members([ 'allowed', 'allowed2' ]) + + expect(data[1].listName).to.equal('forbidden-list') + expect(data[1].words).to.have.members([ 'forbidden' ]) + }) + + it('Should have correctly imported auto tag policies', async function () { + const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' }) + + expect(review).to.have.lengthOf(2) + expect(review).to.have.members([ 'external-link', 'forbidden-list' ]) + }) + it('Should have correctly imported user videos', async function () { const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken }) expect(data).to.have.lengthOf(5) @@ -386,7 +406,7 @@ function runTest (withObjectStorage: boolean) { privacy: (VideoPrivacy.PUBLIC), category: (12), tags: [ 'tag1', 'tag2' ], - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, downloadEnabled: false, nsfw: false, description: ('video description'), @@ -539,6 +559,18 @@ function runTest (withObjectStorage: boolean) { const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken }) expect(data).to.have.lengthOf(5) } + + // Watched words + { + const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' }) + expect(data).to.have.lengthOf(2) + } + + // Auto tag policies + { + const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' }) + expect(review).to.have.lengthOf(2) + } }) it('Should have received an email on finished import', async function () { diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts index 6a38017f5..6d2f05155 100644 --- a/packages/tests/src/api/videos/multiple-servers.ts +++ b/packages/tests/src/api/videos/multiple-servers.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { wait } from '@peertube/peertube-core-utils' -import { HttpStatusCode, VideoCommentThreadTree, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, VideoCommentPolicy, VideoCommentThreadTree, VideoPrivacy } from '@peertube/peertube-models' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { PeerTubeServer, @@ -107,7 +107,7 @@ describe('Test multiple servers', function () { duration: 10, tags: [ 'tag1p1', 'tag2p1' ], privacy: VideoPrivacy.PUBLIC, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, channel: { displayName: 'my channel', @@ -193,7 +193,7 @@ describe('Test multiple servers', function () { name: 'user1', host: servers[1].host }, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, duration: 5, tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], @@ -309,7 +309,7 @@ describe('Test multiple servers', function () { host: servers[2].host }, duration: 5, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, tags: [ 'tag1p3' ], privacy: VideoPrivacy.PUBLIC, @@ -342,7 +342,7 @@ describe('Test multiple servers', function () { name: 'root', host: servers[2].host }, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, duration: 5, tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ], @@ -671,7 +671,7 @@ describe('Test multiple servers', function () { host: servers[2].host }, duration: 5, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, tags: [ 'tag_up_1', 'tag_up_2' ], privacy: VideoPrivacy.PUBLIC, @@ -1022,7 +1022,7 @@ describe('Test multiple servers', function () { this.timeout(20000) const attributes = { - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, downloadEnabled: false } @@ -1033,6 +1033,8 @@ describe('Test multiple servers', function () { for (const server of servers) { const video = await server.videos.get({ id: videoUUID }) expect(video.commentsEnabled).to.be.false + expect(video.commentsPolicy.id).to.equal(VideoCommentPolicy.DISABLED) + expect(video.commentsPolicy.label).to.equal('Disabled') expect(video.downloadEnabled).to.be.false const text = 'my super forbidden comment' @@ -1079,7 +1081,7 @@ describe('Test multiple servers', function () { }, isLocal, duration: 5, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, tags: [], privacy: VideoPrivacy.PUBLIC, diff --git a/packages/tests/src/api/videos/single-server.ts b/packages/tests/src/api/videos/single-server.ts index 82b5fe6ce..1370a2369 100644 --- a/packages/tests/src/api/videos/single-server.ts +++ b/packages/tests/src/api/videos/single-server.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { wait } from '@peertube/peertube-core-utils' -import { Video, VideoPrivacy } from '@peertube/peertube-models' +import { Video, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models' import { checkVideoFilesWereRemoved, completeVideoCheck } from '@tests/shared/videos.js' import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' import { @@ -39,7 +39,7 @@ describe('Test a single server', function () { duration: 5, tags: [ 'tag1', 'tag2', 'tag3' ], privacy: VideoPrivacy.PUBLIC, - commentsEnabled: true, + commentsPolicy: VideoCommentPolicy.ENABLED, downloadEnabled: true, channel: { displayName: 'Main root channel', @@ -72,7 +72,7 @@ describe('Test a single server', function () { tags: [ 'tagup1', 'tagup2' ], privacy: VideoPrivacy.PUBLIC, duration: 5, - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, downloadEnabled: false, channel: { name: 'root_channel', @@ -345,7 +345,7 @@ describe('Test a single server', function () { language: 'ar', nsfw: false, description: 'my super description updated', - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, downloadEnabled: false, tags: [ 'tagup1', 'tagup2' ] } diff --git a/packages/tests/src/api/videos/video-comments.ts b/packages/tests/src/api/videos/video-comments.ts index 7b21287e4..9bbeb0fce 100644 --- a/packages/tests/src/api/videos/video-comments.ts +++ b/packages/tests/src/api/videos/video-comments.ts @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { dateIsValid, testImage } from '@tests/shared/checks.js' import { - cleanupTests, CommentsCommand, - createSingleServer, PeerTubeServer, + cleanupTests, + createSingleServer, setAccessTokensToServers, setDefaultAccountAvatar, setDefaultChannelAvatar } from '@peertube/peertube-server-commands' +import { dateIsValid, testImage } from '@tests/shared/checks.js' +import { expect } from 'chai' describe('Test video comments', function () { let server: PeerTubeServer @@ -246,11 +246,16 @@ describe('Test video comments', function () { }) }) - describe('All instance comments', function () { + describe('Listing comments on my videos and in admin', function () { - it('Should list instance comments as admin', async function () { - { - const { data, total } = await command.listForAdmin({ start: 0, count: 1 }) + const listFunctions = () => ([ + command.listForAdmin.bind(command), + command.listCommentsOnMyVideos.bind(command) + ]) + + it('Should list comments', async function () { + for (const fn of listFunctions()) { + const { data, total } = await fn({ start: 0, count: 1 }) expect(total).to.equal(7) expect(data).to.have.lengthOf(1) @@ -260,8 +265,8 @@ describe('Test video comments', function () { expect(data[0].account.avatars).to.have.lengthOf(4) } - { - const { data, total } = await command.listForAdmin({ start: 1, count: 2 }) + for (const fn of listFunctions()) { + const { data, total } = await fn({ start: 1, count: 2 }) expect(total).to.equal(7) expect(data).to.have.lengthOf(2) @@ -269,6 +274,10 @@ describe('Test video comments', function () { expect(data[0].account.avatars).to.have.lengthOf(4) expect(data[1].account.avatars).to.have.lengthOf(4) } + + const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1 }) + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) }) it('Should filter instance comments by isLocal', async function () { @@ -294,39 +303,92 @@ describe('Test video comments', function () { } }) - it('Should search instance comments by account', async function () { - const { total, data } = await command.listForAdmin({ searchAccount: 'user' }) + it('Should search comments by account', async function () { + for (const fn of listFunctions()) { + const { total, data } = await fn({ searchAccount: 'user' }) - expect(data).to.have.lengthOf(1) - expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(total).to.equal(1) - expect(data[0].text).to.equal('a first answer to thread 4 by a third party') + expect(data[0].text).to.equal('a first answer to thread 4 by a third party') + } + + const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1, searchAccount: 'user' }) + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) }) - it('Should search instance comments by video', async function () { - { - const { total, data } = await command.listForAdmin({ searchVideo: 'video' }) + it('Should search comments by video', async function () { + for (const fn of listFunctions()) { + const { total, data } = await fn({ searchVideo: 'video' }) expect(data).to.have.lengthOf(7) expect(total).to.equal(7) } - { - const { total, data } = await command.listForAdmin({ searchVideo: 'hello' }) + for (const fn of listFunctions()) { + const { total, data } = await fn({ searchVideo: 'hello' }) expect(data).to.have.lengthOf(0) expect(total).to.equal(0) } + + const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1, searchVideo: 'video' }) + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + }) + + it('Should search comments', async function () { + for (const fn of listFunctions()) { + const { total, data } = await fn({ search: 'super thread 3' }) + + expect(total).to.equal(1) + + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('super thread 3') + } + + const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1, search: 'super thread 3' }) + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + }) + + it('Should filter by videoId', async function () { + const { uuid: otherVideo } = await server.videos.upload() + + { + const { total, data } = await command.listForAdmin({ videoId: videoUUID }) + expect(data).to.have.lengthOf(7) + expect(total).to.equal(7) + } + + { + const { total, data } = await command.listForAdmin({ videoId: otherVideo }) + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } }) - it('Should search instance comments', async function () { - const { total, data } = await command.listForAdmin({ search: 'super thread 3' }) + it('Should filter by channelId', async function () { + const { id: videoChannelId } = await server.channels.create({ attributes: { name: 'other_channel' } }) + const { videoChannels: rootChannels } = await server.users.getMyInfo() - expect(total).to.equal(1) + await server.videos.upload({ attributes: { channelId: videoChannelId } }) - expect(data).to.have.lengthOf(1) - expect(data[0].text).to.equal('super thread 3') + { + const { total, data } = await command.listForAdmin({ videoChannelId: rootChannels[0].id }) + expect(data).to.have.lengthOf(7) + expect(total).to.equal(7) + } + + { + const { total, data } = await command.listForAdmin({ videoChannelId }) + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } }) + + // Auto tags filter is checked auto tags test file }) after(async function () { diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts index ed833ffd1..ed36a87bc 100644 --- a/packages/tests/src/feeds/feeds.ts +++ b/packages/tests/src/feeds/feeds.ts @@ -1,25 +1,25 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import * as chai from 'chai' -import chaiJSONSChema from 'chai-json-schema' -import chaiXML from 'chai-xml' -import { XMLParser, XMLValidator } from 'fast-xml-parser' -import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { HttpStatusCode, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models' import { + PeerTubeServer, + PluginsCommand, cleanupTests, createMultipleServers, createSingleServer, doubleFollow, makeGetRequest, makeRawRequest, - PeerTubeServer, - PluginsCommand, setAccessTokensToServers, setDefaultChannelAvatar, setDefaultVideoChannel, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands' +import * as chai from 'chai' +import chaiJSONSChema from 'chai-json-schema' +import chaiXML from 'chai-xml' +import { XMLParser, XMLValidator } from 'fast-xml-parser' chai.use(chaiXML) chai.use(chaiJSONSChema) @@ -39,6 +39,8 @@ describe('Test syndication feeds', () => { let userChannelId: number let userFeedToken: string + let videoIdWithComments: string + let videoIdWithoutComments: string let liveId: string before(async function () { @@ -74,7 +76,8 @@ describe('Test syndication feeds', () => { } { - await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'user video' } }) + const { uuid } = await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'user video' } }) + videoIdWithoutComments = uuid } { @@ -83,10 +86,11 @@ describe('Test syndication feeds', () => { description: 'my super description for server 1', fixture: 'video_short.webm' } - const { id } = await servers[0].videos.upload({ attributes }) + const { uuid } = await servers[0].videos.upload({ attributes }) + videoIdWithComments = uuid - await servers[0].comments.createThread({ videoId: id, text: 'super comment 1' }) - await servers[0].comments.createThread({ videoId: id, text: 'super comment 2' }) + await servers[0].comments.createThread({ videoId: uuid, text: 'super comment 1' }) + await servers[0].comments.createThread({ videoId: uuid, text: 'super comment 2' }) } { @@ -479,6 +483,89 @@ describe('Test syndication feeds', () => { } }) + it('Should filter by videoId', async function () { + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoId: videoIdWithComments }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(2) + } + + { + const json = await servers[0].feed.getJSON({ + feed: 'video-comments', + query: { videoId: videoIdWithoutComments }, + ignoreCache: true + }) + expect(JSON.parse(json).items.length).to.be.equal(0) + } + }) + + it('Should filter by videoChannelId/videoChannelName', async function () { + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoChannelId: rootChannelId }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(2) + } + + { + const json = await servers[0].feed.getJSON({ + feed: 'video-comments', + query: { videoChannelName: 'root_channel' }, + ignoreCache: true + }) + expect(JSON.parse(json).items.length).to.be.equal(2) + } + + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoChannelId: userChannelId }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(0) + } + + { + const json = await servers[0].feed.getJSON({ + feed: 'video-comments', + query: { videoChannelName: 'john_channel' }, + ignoreCache: true + }) + expect(JSON.parse(json).items.length).to.be.equal(0) + } + }) + + it('Should filter by accountId/accountName', async function () { + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountId: rootAccountId }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(2) + } + + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountName: 'root' }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(2) + } + + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountId: userAccountId }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(0) + } + + { + const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountName: 'john' }, ignoreCache: true }) + expect(JSON.parse(json).items.length).to.be.equal(0) + } + }) + + it('Should not list non approved comments', async function () { + await servers[0].videos.update({ id: videoIdWithComments, attributes: { commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL } }) + await servers[0].comments.createThread({ videoId: videoIdWithComments, text: 'approval comment', token: userAccessToken }) + + await waitJobs(servers) + + for (const server of servers) { + const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) + + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(2) + expect(jsonObj.items.some(i => i.content_html.includes('approval'))).to.be.false + } + }) + it('Should not list comments from muted accounts or instances', async function () { this.timeout(30000) diff --git a/packages/tests/src/server-helpers/index.ts b/packages/tests/src/server-helpers/index.ts index 04a26560c..28fd5d99c 100644 --- a/packages/tests/src/server-helpers/index.ts +++ b/packages/tests/src/server-helpers/index.ts @@ -8,3 +8,4 @@ import './mentions.js' import './request.js' import './validator.js' import './version.js' +import './regexp.js' diff --git a/packages/tests/src/server-helpers/regexp.ts b/packages/tests/src/server-helpers/regexp.ts new file mode 100644 index 000000000..ae0a0555b --- /dev/null +++ b/packages/tests/src/server-helpers/regexp.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { wordsToRegExp } from '@peertube/peertube-server/core/helpers/regexp.js' +import { expect } from 'chai' + +describe('Regexp', function () { + + it('Should correctly create a regex from multiple latin words', function () { + const regexp = wordsToRegExp([ 'hi', 'toto ', 'hello picsou' ]) + + expect('hi').to.match(regexp) + expect('coucou toto').to.match(regexp) + expect('coucou toto').to.match(regexp) + + expect('coucoutoto').to.not.match(regexp) + + expect('coucoutoto hello coucou').to.not.match(regexp) + + expect('coucoutoto hello picsou hello').to.match(regexp) + expect('coucoutoto hello picsou').to.match(regexp) + }) + + it('Should correctly create a regex from non latin words', function () { + const regexp = wordsToRegExp([ '🇫🇷', '🇨🇦 hi' ]) + + expect('🇫🇷').to.match(regexp) + expect(' 🇫🇷 ').to.match(regexp) + expect('coucou 🇫🇷 toto').to.match(regexp) + expect('hello 🇨🇦 hi toto').to.match(regexp) + expect('hello 🇨🇦 hi').to.match(regexp) + expect('🇨🇦 hi').to.match(regexp) + expect('🇫🇷🇨🇦 hi').to.match(regexp) + + expect('coucou 🇫🇷toto').to.not.match(regexp) + expect('e🇨🇦 hi').to.not.match(regexp) + expect('hello 🇨🇦 toto').to.not.match(regexp) + }) + + it('Should correctly create a regex from single word', function () { + const regexp = wordsToRegExp([ 'hi my friend' ]) + + expect('hi').to.not.match(regexp) + expect('hi my friend').to.match(regexp) + expect(' hi my friend ').to.match(regexp) + + expect(' hi my friendy ').to.not.match(regexp) + }) + + it('Should correctly escape words to regex', function () { + const regexp = wordsToRegExp([ 'hi[hello]', 'toto. ', 'coucou+' ]) + + expect('1 2 toto. 3').to.match(regexp) + expect('hi[hello]').to.match(regexp) + expect(' coucou coucou+').to.match(regexp) + + expect('hihello').to.not.match(regexp) + expect('totoa').to.not.match(regexp) + expect('1 2 toto 3').to.not.match(regexp) + }) +}) diff --git a/packages/tests/src/shared/import-export.ts b/packages/tests/src/shared/import-export.ts index c9377f7b9..616ceb1f7 100644 --- a/packages/tests/src/shared/import-export.ts +++ b/packages/tests/src/shared/import-export.ts @@ -7,6 +7,7 @@ import { UserExport, UserNotificationSettingValue, VideoCommentObject, + VideoCommentPolicy, VideoObject, VideoPlaylistPrivacy, VideoPrivacy @@ -202,7 +203,7 @@ export async function prepareImportExportTests (options: { name: 'noah public video second channel', category: 12, tags: [ 'tag1', 'tag2' ], - commentsEnabled: false, + commentsPolicy: VideoCommentPolicy.DISABLED, description: 'video description', downloadEnabled: false, language: 'fr', @@ -315,6 +316,27 @@ export async function prepareImportExportTests (options: { await server.views.view({ id: noahVideo.uuid, token: noahToken, currentTime: 4 }) await server.views.view({ id: externalVideo.uuid, token: noahToken, currentTime: 2 }) + // Watched words and auto tag policies + await servers[0].watchedWordsLists.createList({ + token: noahToken, + listName: 'forbidden-list', + words: [ 'forbidden' ], + accountName: 'noah' + }) + + await servers[0].watchedWordsLists.createList({ + token: noahToken, + listName: 'allowed-list', + words: [ 'allowed', 'allowed2' ], + accountName: 'noah' + }) + + await servers[0].autoTags.updateCommentPolicies({ + accountName: 'noah', + review: [ 'external-link', 'forbidden-list' ], + token: noahToken + }) + return { rootId, diff --git a/packages/tests/src/shared/notifications.ts b/packages/tests/src/shared/notifications.ts index c3612f790..5f4b240f9 100644 --- a/packages/tests/src/shared/notifications.ts +++ b/packages/tests/src/shared/notifications.ts @@ -437,8 +437,9 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & { commentId: number threadId: number checkType: CheckerType + approval?: boolean // default false }) { - const { server, shortUUID, commentId, threadId, checkType, emails } = options + const { server, shortUUID, commentId, threadId, checkType, emails, approval = false } = options const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO function notificationChecker (notification: UserNotification, checkType: CheckerType) { @@ -449,6 +450,8 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & { checkComment(notification.comment, commentId, threadId) checkActor(notification.comment.account) checkVideo(notification.comment.video, undefined, shortUUID) + + expect(notification.comment.heldForReview).to.equal(approval) } else { expect(notification).to.satisfy((n: UserNotification) => { return n?.comment === undefined || n.comment.id !== commentId @@ -456,10 +459,16 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & { } } - const commentUrl = `${server.url}/w/${shortUUID};threadId=${threadId}` + const commentUrl = approval + ? `${server.url}/my-account/videos/comments?search=heldForReview:true` + : `${server.url}/w/${shortUUID};threadId=${threadId}` function emailNotificationFinder (email: object) { - return email['text'].indexOf(commentUrl) !== -1 + const text = email['text'] + + return text.includes(commentUrl) && + (approval && text.includes('requires approval')) || + (!approval && !text.includes('requires approval')) } await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts index acf647ac1..1fe43d0c9 100644 --- a/packages/tests/src/shared/videos.ts +++ b/packages/tests/src/shared/videos.ts @@ -1,7 +1,16 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ import { uuidRegex } from '@peertube/peertube-core-utils' -import { HttpStatusCode, HttpStatusCodeType, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' +import { + HttpStatusCode, + HttpStatusCodeType, + VideoCaption, + VideoCommentPolicy, + VideoCommentPolicyType, + VideoDetails, + VideoPrivacy, + VideoResolution +} from '@peertube/peertube-models' import { buildAbsoluteFixturePath, getFileSize, getFilenameFromUrl, getLowercaseExtension } from '@peertube/peertube-node-utils' import { PeerTubeServer, VideoEdit, getRedirectionUrl, makeRawRequest, waitJobs } from '@peertube/peertube-server-commands' import { @@ -139,7 +148,7 @@ export async function completeVideoCheck (options: { licence: number language: string nsfw: boolean - commentsEnabled: boolean + commentsPolicy: VideoCommentPolicyType downloadEnabled: boolean description: string support: string @@ -214,7 +223,8 @@ export async function completeVideoCheck (options: { expect(video.url).to.contain(originServer.host) expect(video.tags).to.deep.equal(attributes.tags) - expect(video.commentsEnabled).to.equal(attributes.commentsEnabled) + expect(video.commentsEnabled).to.equal(attributes.commentsPolicy !== VideoCommentPolicy.DISABLED) + expect(video.commentsPolicy.id).to.equal(attributes.commentsPolicy) expect(video.downloadEnabled).to.equal(attributes.downloadEnabled) expect(dateIsValid(video.createdAt)).to.be.true diff --git a/server/core/assets/email-templates/user-import-completed/html.pug b/server/core/assets/email-templates/user-import-completed/html.pug index 5805cd5d4..ddd6f023d 100644 --- a/server/core/assets/email-templates/user-import-completed/html.pug +++ b/server/core/assets/email-templates/user-import-completed/html.pug @@ -47,3 +47,9 @@ block content li strong Video history: +displaySummary(resultStats.userVideoHistory) + li + strong Watched Words Lists: + +displaySummary(resultStats.watchedWordsLists) + li + strong Comment auto tag policies: + +displaySummary(resultStats.commentAutoTagPolicies) diff --git a/server/core/assets/email-templates/video-comment-new/html.pug b/server/core/assets/email-templates/video-comment-new/html.pug index cbb683fee..a11787026 100644 --- a/server/core/assets/email-templates/video-comment-new/html.pug +++ b/server/core/assets/email-templates/video-comment-new/html.pug @@ -7,5 +7,10 @@ block content p. #[a(href=accountUrl title=handle) #{accountName}] added a comment on your video "#[a(href=videoUrl) #{video.name}]": + blockquote !{commentHtml} + + if requiresApproval + | This comment requires approval. + br(style="display: none;") diff --git a/server/core/controllers/activitypub/client.ts b/server/core/controllers/activitypub/client.ts index c725f05e3..eb8770074 100644 --- a/server/core/controllers/activitypub/client.ts +++ b/server/core/controllers/activitypub/client.ts @@ -1,6 +1,5 @@ -import cors from 'cors' -import express from 'express' import { + HttpStatusCode, VideoChaptersObject, VideoCommentObject, VideoPlaylistPrivacy, @@ -9,12 +8,17 @@ import { } from '@peertube/peertube-models' import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' import { getContextFilter } from '@server/lib/activitypub/context.js' +import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js' +import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' import { getServerActor } from '@server/models/application/application.js' +import { VideoChapterModel } from '@server/models/video/video-chapter.js' import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js' +import cors from 'cors' +import express from 'express' import { activityPubContextify } from '../../helpers/activity-pub-utils.js' import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js' import { audiencify, getAudience } from '../../lib/activitypub/audience.js' -import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/activitypub/send/index.js' +import { buildAnnounceWithVideoAudience, buildApprovalActivity, buildLikeActivity } from '../../lib/activitypub/send/index.js' import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' import { @@ -54,9 +58,6 @@ import { VideoCommentModel } from '../../models/video/video-comment.js' import { VideoPlaylistModel } from '../../models/video/video-playlist.js' import { VideoShareModel } from '../../models/video/video-share.js' import { activityPubResponse } from './utils.js' -import { VideoChapterModel } from '@server/models/video/video-chapter.js' -import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' -import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js' const activityPubClientRouter = express.Router() activityPubClientRouter.use(cors()) @@ -141,19 +142,28 @@ activityPubClientRouter.get('/videos/watch/:id/dislikes', asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')), asyncMiddleware(videoDislikesController) ) + +// --------------------------------------------------------------------------- + activityPubClientRouter.get('/videos/watch/:id/comments', executeIfActivityPub, activityPubRateLimiter, asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')), asyncMiddleware(videoCommentsController) ) -activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId', +activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/approve-reply', + executeIfActivityPub, + activityPubRateLimiter, + asyncMiddleware(videoCommentGetValidator), + asyncMiddleware(videoCommentApprovedController) +) +activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity', executeIfActivityPub, activityPubRateLimiter, asyncMiddleware(videoCommentGetValidator), asyncMiddleware(videoCommentController) ) -activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity', +activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId', executeIfActivityPub, activityPubRateLimiter, asyncMiddleware(videoCommentGetValidator), @@ -408,8 +418,10 @@ async function videoCommentController (req: express.Request, res: express.Respon const videoComment = res.locals.videoCommentFull if (redirectIfNotOwned(videoComment.url, res)) return + if (videoComment.Video.isOwned() && videoComment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) + + const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment }) - const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined) const isPublic = true // Comments are always public let videoCommentObject = videoComment.toActivityPubObject(threadParentComments) @@ -426,6 +438,16 @@ async function videoCommentController (req: express.Request, res: express.Respon return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res) } +async function videoCommentApprovedController (req: express.Request, res: express.Response) { + const comment = res.locals.videoCommentFull + + if (!comment.Video.isOwned() || comment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) + + const activity = buildApprovalActivity({ comment, type: 'ApproveReply' }) + + return activityPubResponse(activityPubContextify(activity, 'ApproveReply', getContextFilter()), res) +} + async function videoChaptersController (req: express.Request, res: express.Response) { const video = res.locals.onlyVideo diff --git a/server/core/controllers/api/automatic-tags.ts b/server/core/controllers/api/automatic-tags.ts new file mode 100644 index 000000000..e107e3101 --- /dev/null +++ b/server/core/controllers/api/automatic-tags.ts @@ -0,0 +1,82 @@ +import { AutomaticTagPolicy, CommentAutomaticTagPoliciesUpdate, HttpStatusCode, UserRight } from '@peertube/peertube-models' +import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js' +import { setAccountAutomaticTagsPolicy } from '@server/lib/automatic-tags/automatic-tags.js' +import { + manageAccountAutomaticTagsValidator, + updateAutomaticTagPoliciesValidator +} from '@server/middlewares/validators/automatic-tags.js' +import { getServerActor } from '@server/models/application/application.js' +import express from 'express' +import { + apiRateLimiter, + asyncMiddleware, + authenticate, + ensureUserHasRight +} from '../../middlewares/index.js' + +const automaticTagRouter = express.Router() + +automaticTagRouter.use(apiRateLimiter) + +automaticTagRouter.get('/policies/accounts/:accountName/comments', + authenticate, + asyncMiddleware(manageAccountAutomaticTagsValidator), + asyncMiddleware(getAutomaticTagPolicies) +) + +automaticTagRouter.put('/policies/accounts/:accountName/comments', + authenticate, + asyncMiddleware(manageAccountAutomaticTagsValidator), + asyncMiddleware(updateAutomaticTagPoliciesValidator), + asyncMiddleware(updateAutomaticTagPolicies) +) + +// --------------------------------------------------------------------------- + +automaticTagRouter.get('/accounts/:accountName/available', + authenticate, + asyncMiddleware(manageAccountAutomaticTagsValidator), + asyncMiddleware(getAccountAutomaticTagAvailable) +) + +automaticTagRouter.get('/server/available', + authenticate, + ensureUserHasRight(UserRight.MANAGE_INSTANCE_AUTO_TAGS), + asyncMiddleware(getServerAutomaticTagAvailable) +) + +// --------------------------------------------------------------------------- + +export { + automaticTagRouter +} + +// --------------------------------------------------------------------------- + +async function getAutomaticTagPolicies (req: express.Request, res: express.Response) { + const result = await AutomaticTagger.getAutomaticTagPolicies(res.locals.account) + + return res.json(result) +} + +async function updateAutomaticTagPolicies (req: express.Request, res: express.Response) { + await setAccountAutomaticTagsPolicy({ + account: res.locals.account, + policy: AutomaticTagPolicy.REVIEW_COMMENT, + tags: (req.body as CommentAutomaticTagPoliciesUpdate).review + }) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function getAccountAutomaticTagAvailable (req: express.Request, res: express.Response) { + const result = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account) + + return res.json(result) +} + +async function getServerAutomaticTagAvailable (req: express.Request, res: express.Response) { + const result = await AutomaticTagger.getAutomaticTagAvailable((await getServerActor()).Account) + + return res.json(result) +} diff --git a/server/core/controllers/api/index.ts b/server/core/controllers/api/index.ts index b48a6645e..d5034c1de 100644 --- a/server/core/controllers/api/index.ts +++ b/server/core/controllers/api/index.ts @@ -1,9 +1,10 @@ +import { HttpStatusCode } from '@peertube/peertube-models' +import { logger } from '@server/helpers/logger.js' import cors from 'cors' import express from 'express' -import { logger } from '@server/helpers/logger.js' -import { HttpStatusCode } from '@peertube/peertube-models' import { abuseRouter } from './abuse.js' import { accountsRouter } from './accounts.js' +import { automaticTagRouter } from './automatic-tags.js' import { blocklistRouter } from './blocklist.js' import { bulkRouter } from './bulk.js' import { configRouter } from './config.js' @@ -21,6 +22,7 @@ import { videoChannelSyncRouter } from './video-channel-sync.js' import { videoChannelRouter } from './video-channel.js' import { videoPlaylistRouter } from './video-playlist.js' import { videosRouter } from './videos/index.js' +import { watchedWordsRouter } from './watched-words.js' const apiRouter = express.Router() @@ -49,6 +51,8 @@ apiRouter.use('/plugins', pluginRouter) apiRouter.use('/custom-pages', customPageRouter) apiRouter.use('/blocklist', blocklistRouter) apiRouter.use('/runners', runnersRouter) +apiRouter.use('/watched-words', watchedWordsRouter) +apiRouter.use('/automatic-tags', automaticTagRouter) apiRouter.use('/ping', pong) apiRouter.use('/*', badRequest) diff --git a/server/core/controllers/api/users/me.ts b/server/core/controllers/api/users/me.ts index 8f85788c1..1dadb6e43 100644 --- a/server/core/controllers/api/users/me.ts +++ b/server/core/controllers/api/users/me.ts @@ -1,16 +1,17 @@ -import 'multer' -import express from 'express' import { pick } from '@peertube/peertube-core-utils' import { ActorImageType, + UserVideoRate as FormattedUserVideoRate, HttpStatusCode, UserUpdateMe, - UserVideoQuota, - UserVideoRate as FormattedUserVideoRate + UserVideoQuota } from '@peertube/peertube-models' -import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger.js' -import { Hooks } from '@server/lib/plugins/hooks.js' import { AttributesOnly } from '@peertube/peertube-typescript-utils' +import { UserAuditView, auditLoggerFactory, getAuditIdFromRes } from '@server/helpers/audit-logger.js' +import { Hooks } from '@server/lib/plugins/hooks.js' +import { VideoCommentModel } from '@server/models/video/video-comment.js' +import express from 'express' +import 'multer' import { createReqFiles } from '../../../helpers/express-utils.js' import { getFormattedObjects } from '../../../helpers/utils.js' import { CONFIG } from '../../../initializers/config.js' @@ -34,6 +35,7 @@ import { updateAvatarValidator } from '../../../middlewares/validators/actor-ima import { deleteMeValidator, getMyVideoImportsValidator, + listCommentsOnUserVideosValidator, usersVideosValidator, videoImportsSortValidator, videosSortValidator @@ -75,6 +77,16 @@ meRouter.get('/me/videos/imports', asyncMiddleware(getUserVideoImports) ) +meRouter.get('/me/videos/comments', + authenticate, + paginationValidator, + videosSortValidator, + setDefaultVideosSort, + setDefaultPagination, + asyncMiddleware(listCommentsOnUserVideosValidator), + asyncMiddleware(listCommentsOnUserVideos) +) + meRouter.get('/me/videos', authenticate, paginationValidator, @@ -82,7 +94,7 @@ meRouter.get('/me/videos', setDefaultVideosSort, setDefaultPagination, asyncMiddleware(usersVideosValidator), - asyncMiddleware(getUserVideos) + asyncMiddleware(listUserVideos) ) meRouter.get('/me/videos/:videoId/rating', @@ -117,7 +129,7 @@ export { // --------------------------------------------------------------------------- -async function getUserVideos (req: express.Request, res: express.Response) { +async function listUserVideos (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User const apiOptions = await Hooks.wrapObject({ @@ -145,6 +157,36 @@ async function getUserVideos (req: express.Request, res: express.Response) { return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes })) } +async function listCommentsOnUserVideos (req: express.Request, res: express.Response) { + const userAccount = res.locals.oauth.token.User.Account + + const options = { + ...pick(req.query, [ + 'start', + 'count', + 'sort', + 'search', + 'searchAccount', + 'searchVideo', + 'autoTagOneOf' + ]), + + autoTagOfAccountId: userAccount.id, + videoAccountOwnerId: userAccount.id, + heldForReview: req.query.isHeldForReview, + + videoChannelOwnerId: res.locals.videoChannel?.id, + videoId: res.locals.videoAll?.id + } + + const resultList = await VideoCommentModel.listCommentsForApi(options) + + return res.json({ + total: resultList.total, + data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON()) + }) +} + async function getUserVideoImports (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User const resultList = await VideoImportModel.listUserVideoImportsForApi({ diff --git a/server/core/controllers/api/videos/comment.ts b/server/core/controllers/api/videos/comment.ts index e7fe6ca43..8fa731db9 100644 --- a/server/core/controllers/api/videos/comment.ts +++ b/server/core/controllers/api/videos/comment.ts @@ -1,19 +1,21 @@ -import express from 'express' +import { pick } from '@peertube/peertube-core-utils' import { HttpStatusCode, ResultList, ThreadsResultList, UserRight, VideoCommentCreate, + VideoCommentPolicy, VideoCommentThreads } from '@peertube/peertube-models' +import { getServerActor } from '@server/models/application/application.js' import { MCommentFormattable } from '@server/types/models/index.js' -import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger.js' +import express from 'express' +import { CommentAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js' import { getFormattedObjects } from '../../../helpers/utils.js' -import { sequelizeTypescript } from '../../../initializers/database.js' import { Notifier } from '../../../lib/notifier/index.js' import { Hooks } from '../../../lib/plugins/hooks.js' -import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment.js' +import { approveComment, buildFormattedCommentTree, createLocalVideoComment, removeComment } from '../../../lib/video-comment.js' import { asyncMiddleware, asyncRetryTransactionMiddleware, @@ -27,14 +29,14 @@ import { import { addVideoCommentReplyValidator, addVideoCommentThreadValidator, - listVideoCommentsValidator, + approveVideoCommentValidator, + listAllVideoCommentsForAdminValidator, listVideoCommentThreadsValidator, listVideoThreadCommentsValidator, removeVideoCommentValidator, - videoCommentsValidator, - videoCommentThreadsSortValidator + videoCommentThreadsSortValidator, + videoCommentsValidator } from '../../../middlewares/validators/index.js' -import { AccountModel } from '../../../models/account/account.js' import { VideoCommentModel } from '../../../models/video/video-comment.js' const auditLogger = auditLoggerFactory('comments') @@ -71,6 +73,12 @@ videoCommentRouter.delete('/:videoId/comments/:commentId', asyncRetryTransactionMiddleware(removeVideoComment) ) +videoCommentRouter.post('/:videoId/comments/:commentId/approve', + authenticate, + asyncMiddleware(approveVideoCommentValidator), + asyncMiddleware(approveVideoComment) +) + videoCommentRouter.get('/comments', authenticate, ensureUserHasRight(UserRight.SEE_ALL_COMMENTS), @@ -78,7 +86,7 @@ videoCommentRouter.get('/comments', videoCommentsValidator, setDefaultSort, setDefaultPagination, - listVideoCommentsValidator, + asyncMiddleware(listAllVideoCommentsForAdminValidator), asyncMiddleware(listComments) ) @@ -92,22 +100,29 @@ export { async function listComments (req: express.Request, res: express.Response) { const options = { - start: req.query.start, - count: req.query.count, - sort: req.query.sort, + ...pick(req.query, [ + 'start', + 'count', + 'sort', + 'isLocal', + 'onLocalVideo', + 'search', + 'searchAccount', + 'searchVideo', + 'autoTagOneOf' + ]), - isLocal: req.query.isLocal, - onLocalVideo: req.query.onLocalVideo, - search: req.query.search, - searchAccount: req.query.searchAccount, - searchVideo: req.query.searchVideo + videoId: res.locals.onlyImmutableVideo?.id, + videoChannelOwnerId: res.locals.videoChannel?.id, + autoTagOfAccountId: (await getServerActor()).Account.id, + heldForReview: undefined } const resultList = await VideoCommentModel.listCommentsForApi(options) return res.json({ total: resultList.total, - data: resultList.data.map(c => c.toFormattedAdminJSON()) + data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON()) }) } @@ -117,10 +132,9 @@ async function listVideoThreads (req: express.Request, res: express.Response) { let resultList: ThreadsResultList<MCommentFormattable> - if (video.commentsEnabled === true) { + if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) { const apiOptions = await Hooks.wrapObject({ - videoId: video.id, - isVideoOwned: video.isOwned(), + video, start: req.query.start, count: req.query.count, sort: req.query.sort, @@ -152,9 +166,9 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo let resultList: ResultList<MCommentFormattable> - if (video.commentsEnabled === true) { + if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) { const apiOptions = await Hooks.wrapObject({ - videoId: video.id, + video, threadId: res.locals.videoCommentThread.id, user }, 'filter:api.video-thread-comments.list.params') @@ -184,15 +198,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo async function addVideoCommentThread (req: express.Request, res: express.Response) { const videoCommentInfo: VideoCommentCreate = req.body - const comment = await sequelizeTypescript.transaction(async t => { - const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) - - return createVideoComment({ - text: videoCommentInfo.text, - inReplyToComment: null, - video: res.locals.videoAll, - account - }, t) + const comment = await createLocalVideoComment({ + text: videoCommentInfo.text, + inReplyToComment: null, + video: res.locals.videoAll, + user: res.locals.oauth.token.User }) Notifier.Instance.notifyOnNewComment(comment) @@ -206,15 +216,11 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons async function addVideoCommentReply (req: express.Request, res: express.Response) { const videoCommentInfo: VideoCommentCreate = req.body - const comment = await sequelizeTypescript.transaction(async t => { - const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) - - return createVideoComment({ - text: videoCommentInfo.text, - inReplyToComment: res.locals.videoCommentFull, - video: res.locals.videoAll, - account - }, t) + const comment = await createLocalVideoComment({ + text: videoCommentInfo.text, + inReplyToComment: res.locals.videoCommentFull, + video: res.locals.videoAll, + user: res.locals.oauth.token.User }) Notifier.Instance.notifyOnNewComment(comment) @@ -226,13 +232,17 @@ async function addVideoCommentReply (req: express.Request, res: express.Response } async function removeVideoComment (req: express.Request, res: express.Response) { - const videoCommentInstance = res.locals.videoCommentFull + const comment = res.locals.videoCommentFull - await removeComment(videoCommentInstance, req, res) + await removeComment(comment, req, res) - auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON())) + auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) - return res.type('json') - .status(HttpStatusCode.NO_CONTENT_204) - .end() + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function approveVideoComment (req: express.Request, res: express.Response) { + await approveComment(res.locals.videoCommentFull) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) } diff --git a/server/core/controllers/api/videos/update.ts b/server/core/controllers/api/videos/update.ts index 965dd829a..23c53f7ad 100644 --- a/server/core/controllers/api/videos/update.ts +++ b/server/core/controllers/api/videos/update.ts @@ -1,8 +1,10 @@ import { forceNumber } from '@peertube/peertube-core-utils' -import { HttpStatusCode, ThumbnailType, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models' +import { HttpStatusCode, ThumbnailType, VideoCommentPolicy, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models' import { exists } from '@server/helpers/custom-validators/misc.js' import { changeVideoChannelShare } from '@server/lib/activitypub/share.js' import { isNewVideoPrivacyForFederation, isPrivacyForFederation } from '@server/lib/activitypub/videos/federate.js' +import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js' +import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js' import { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js' import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js' @@ -65,6 +67,7 @@ async function updateVideo (req: express.Request, res: express.Response) { // Refresh video since thumbnails to prevent concurrent updates const video = await VideoModel.loadFull(videoFromReq.id, t) + const oldName = video.name const oldDescription = video.description const oldVideoChannel = video.VideoChannel @@ -77,7 +80,6 @@ async function updateVideo (req: express.Request, res: express.Response) { 'waitTranscoding', 'support', 'description', - 'commentsEnabled', 'downloadEnabled' ] @@ -85,6 +87,15 @@ async function updateVideo (req: express.Request, res: express.Response) { if (videoInfoToUpdate[key] !== undefined) video.set(key, videoInfoToUpdate[key]) } + // Special treatment for comments policy to support deprecated commentsEnabled attribute + if (videoInfoToUpdate.commentsPolicy !== undefined) { + video.commentsPolicy = videoInfoToUpdate.commentsPolicy + } else if (videoInfoToUpdate.commentsEnabled === true) { + video.commentsPolicy = VideoCommentPolicy.ENABLED + } else if (videoInfoToUpdate.commentsEnabled === false) { + video.commentsPolicy = VideoCommentPolicy.DISABLED + } + if (videoInfoToUpdate.originallyPublishedAt !== undefined) { video.originallyPublishedAt = videoInfoToUpdate.originallyPublishedAt ? new Date(videoInfoToUpdate.originallyPublishedAt) @@ -142,6 +153,11 @@ async function updateVideo (req: express.Request, res: express.Response) { }) } + if (oldName !== video.name || oldDescription !== video.description) { + const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction: t }) + await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction: t }) + } + await autoBlacklistVideoIfNeeded({ video: videoInstanceUpdated, user: res.locals.oauth.token.User, diff --git a/server/core/controllers/api/watched-words.ts b/server/core/controllers/api/watched-words.ts new file mode 100644 index 000000000..800257dad --- /dev/null +++ b/server/core/controllers/api/watched-words.ts @@ -0,0 +1,162 @@ +import { HttpStatusCode, UserRight } from '@peertube/peertube-models' +import { Awaitable } from '@peertube/peertube-typescript-utils' +import { + addWatchedWordsListValidatorFactory, + getWatchedWordsListValidatorFactory, + manageAccountWatchedWordsListValidator, + updateWatchedWordsListValidatorFactory +} from '@server/middlewares/validators/watched-words.js' +import { getServerActor } from '@server/models/application/application.js' +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' +import { MAccountId } from '@server/types/models/index.js' +import express from 'express' +import { getFormattedObjects } from '../../helpers/utils.js' +import { + apiRateLimiter, + asyncMiddleware, + authenticate, ensureUserHasRight, paginationValidator, + setDefaultPagination, + setDefaultSort, + watchedWordsListsSortValidator +} from '../../middlewares/index.js' + +const watchedWordsRouter = express.Router() + +watchedWordsRouter.use(apiRateLimiter) + +{ + const common = [ + authenticate, + paginationValidator, + watchedWordsListsSortValidator, + setDefaultSort, + setDefaultPagination + ] + + watchedWordsRouter.get('/accounts/:accountName/lists', + ...common, + + asyncMiddleware(manageAccountWatchedWordsListValidator), + asyncMiddleware(listWatchedWordsListsFactory(res => res.locals.account)) + ) + + watchedWordsRouter.get('/server/lists', + ...common, + + ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS), + asyncMiddleware(listWatchedWordsListsFactory(() => getServerActor().then(a => a.Account))) + ) +} + +// --------------------------------------------------------------------------- + +{ + watchedWordsRouter.post('/accounts/:accountName/lists', + authenticate, + asyncMiddleware(manageAccountWatchedWordsListValidator), + asyncMiddleware(addWatchedWordsListValidatorFactory(res => res.locals.account)), + asyncMiddleware(addWatchedWordsListFactory(res => res.locals.account)) + ) + + watchedWordsRouter.post('/server/lists', + authenticate, + ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS), + asyncMiddleware(addWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))), + asyncMiddleware(addWatchedWordsListFactory(() => getServerActor().then(a => a.Account))) + ) +} + +// --------------------------------------------------------------------------- + +{ + watchedWordsRouter.put('/accounts/:accountName/lists/:listId', + authenticate, + asyncMiddleware(manageAccountWatchedWordsListValidator), + asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)), + asyncMiddleware(updateWatchedWordsListValidatorFactory(res => res.locals.account)), + asyncMiddleware(updateWatchedWordsList) + ) + + watchedWordsRouter.put('/server/lists/:listId', + authenticate, + ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS), + asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))), + asyncMiddleware(updateWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))), + asyncMiddleware(updateWatchedWordsList) + ) +} + +// --------------------------------------------------------------------------- + +{ + watchedWordsRouter.delete('/accounts/:accountName/lists/:listId', + authenticate, + asyncMiddleware(manageAccountWatchedWordsListValidator), + asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)), + asyncMiddleware(deleteWatchedWordsList) + ) + + watchedWordsRouter.delete('/server/lists/:listId', + authenticate, + ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS), + asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))), + asyncMiddleware(deleteWatchedWordsList) + ) +} + +// --------------------------------------------------------------------------- + +export { + watchedWordsRouter +} + +// --------------------------------------------------------------------------- + +function listWatchedWordsListsFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) { + return async (req: express.Request, res: express.Response) => { + const resultList = await WatchedWordsListModel.listForAPI({ + accountId: (await accountGetter(res)).id, + start: req.query.start, + count: req.query.count, + sort: req.query.sort + }) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) + } +} + +function addWatchedWordsListFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) { + return async (req: express.Request, res: express.Response) => { + const list = await WatchedWordsListModel.createList({ + accountId: (await accountGetter(res)).id, + + listName: req.body.listName, + words: req.body.words + }) + + return res.json({ + watchedWordsList: { + id: list.id + } + }) + } +} + +async function updateWatchedWordsList (req: express.Request, res: express.Response) { + const list = res.locals.watchedWordsList + + await list.updateList({ + listName: req.body.listName, + words: req.body.words + }) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function deleteWatchedWordsList (req: express.Request, res: express.Response) { + const list = res.locals.watchedWordsList + + await list.destroy() + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/core/controllers/feeds/comment-feeds.ts b/server/core/controllers/feeds/comment-feeds.ts index 4178f198e..105ae27ac 100644 --- a/server/core/controllers/feeds/comment-feeds.ts +++ b/server/core/controllers/feeds/comment-feeds.ts @@ -1,14 +1,14 @@ -import express from 'express' import { toSafeHtml } from '@server/helpers/markdown.js' import { cacheRouteFactory } from '@server/middlewares/index.js' +import express from 'express' import { CONFIG } from '../../initializers/config.js' import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js' import { asyncMiddleware, + feedsAccountOrChannelFiltersValidator, feedsFormatValidator, setFeedFormatContentType, - videoCommentsFeedsValidator, - feedsAccountOrChannelFiltersValidator + videoCommentsFeedsValidator } from '../../middlewares/index.js' import { VideoCommentModel } from '../../models/video/video-comment.js' import { buildFeedMetadata, initFeed, sendFeed } from './shared/index.js' @@ -49,9 +49,9 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res const comments = await VideoCommentModel.listForFeed({ start, count: CONFIG.FEEDS.COMMENTS.COUNT, - videoId: video ? video.id : undefined, - accountId: account ? account.id : undefined, - videoChannelId: videoChannel ? videoChannel.id : undefined + videoId: video?.id, + videoAccountOwnerId: account?.id, + videoChannelOwnerId: videoChannel?.id }) const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel }) diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts index 9ff88370d..525793795 100644 --- a/server/core/helpers/activity-pub-utils.ts +++ b/server/core/helpers/activity-pub-utils.ts @@ -49,7 +49,7 @@ export async function getApplicationActorOfHost (host: string) { return found?.href || undefined } -export function getAPPublicValue () { +export function getAPPublicValue (): 'https://www.w3.org/ns/activitystreams#Public' { return 'https://www.w3.org/ns/activitystreams#Public' } @@ -137,10 +137,19 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string '@type': 'sc:Number', '@id': 'pt:fps' }, + + // Keep for federation compatibility commentsEnabled: { '@type': 'sc:Boolean', '@id': 'pt:commentsEnabled' }, + + canReply: 'pt:canReply', + commentsPolicy: { + '@type': 'sc:Number', + '@id': 'pt:commentsPolicy' + }, + downloadEnabled: { '@type': 'sc:Boolean', '@id': 'pt:downloadEnabled' @@ -261,10 +270,21 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string Reject: buildContext(), Accept: buildContext(), Announce: buildContext(), - Comment: buildContext(), + + Comment: buildContext({ + replyApproval: 'pt:replyApproval' + }), + Delete: buildContext(), Rate: buildContext(), + ApproveReply: buildContext({ + ApproveReply: 'pt:ApproveReply' + }), + RejectReply: buildContext({ + RejectReply: 'pt:RejectReply' + }), + Chapters: buildContext({ hasPart: 'sc:hasPart', endOffset: 'sc:endOffset', diff --git a/server/core/helpers/audit-logger.ts b/server/core/helpers/audit-logger.ts index 5958acbf5..c4b95faae 100644 --- a/server/core/helpers/audit-logger.ts +++ b/server/core/helpers/audit-logger.ts @@ -127,7 +127,7 @@ const videoKeysToKeep = new Set([ 'channel-uuid', 'channel-name', 'support', - 'commentsEnabled', + 'commentsPolicy', 'downloadEnabled' ]) class VideoAuditView extends EntityAuditView { diff --git a/server/core/helpers/custom-validators/activitypub/activity.ts b/server/core/helpers/custom-validators/activitypub/activity.ts index 05399ebdf..83cb06062 100644 --- a/server/core/helpers/custom-validators/activitypub/activity.ts +++ b/server/core/helpers/custom-validators/activitypub/activity.ts @@ -10,7 +10,7 @@ import { sanitizeAndCheckVideoCommentObject } from './video-comments.js' import { sanitizeAndCheckVideoTorrentObject } from './videos.js' import { isWatchActionObjectValid } from './watch-action.js' -function isRootActivityValid (activity: any) { +export function isRootActivityValid (activity: any) { return isCollection(activity) || isActivity(activity) } @@ -26,6 +26,8 @@ function isActivity (activity: any) { (isActivityPubUrlValid(activity.actor) || isActivityPubUrlValid(activity.actor.id)) } +// --------------------------------------------------------------------------- + const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean } = { Create: isCreateActivityValid, Update: isUpdateActivityValid, @@ -38,10 +40,12 @@ const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean Like: isLikeActivityValid, View: isViewActivityValid, Flag: isFlagActivityValid, - Dislike: isDislikeActivityValid + Dislike: isDislikeActivityValid, + ApproveReply: isApproveReplyActivityValid, + RejectReply: isRejectReplyActivityValid } -function isActivityValid (activity: any) { +export function isActivityValid (activity: any) { const checker = activityCheckers[activity.type] // Unknown activity type if (!checker) return false @@ -49,34 +53,34 @@ function isActivityValid (activity: any) { return checker(activity) } -function isFlagActivityValid (activity: any) { +export function isFlagActivityValid (activity: any) { return isBaseActivityValid(activity, 'Flag') && isAbuseReasonValid(activity.content) && isActivityPubUrlValid(activity.object) } -function isLikeActivityValid (activity: any) { +export function isLikeActivityValid (activity: any) { return isBaseActivityValid(activity, 'Like') && isObjectValid(activity.object) } -function isDislikeActivityValid (activity: any) { +export function isDislikeActivityValid (activity: any) { return isBaseActivityValid(activity, 'Dislike') && isObjectValid(activity.object) } -function isAnnounceActivityValid (activity: any) { +export function isAnnounceActivityValid (activity: any) { return isBaseActivityValid(activity, 'Announce') && isObjectValid(activity.object) } -function isViewActivityValid (activity: any) { +export function isViewActivityValid (activity: any) { return isBaseActivityValid(activity, 'View') && isActivityPubUrlValid(activity.actor) && isActivityPubUrlValid(activity.object) } -function isCreateActivityValid (activity: any) { +export function isCreateActivityValid (activity: any) { return isBaseActivityValid(activity, 'Create') && ( isViewActivityValid(activity.object) || @@ -91,7 +95,7 @@ function isCreateActivityValid (activity: any) { ) } -function isUpdateActivityValid (activity: any) { +export function isUpdateActivityValid (activity: any) { return isBaseActivityValid(activity, 'Update') && ( isCacheFileObjectValid(activity.object) || @@ -101,26 +105,26 @@ function isUpdateActivityValid (activity: any) { ) } -function isDeleteActivityValid (activity: any) { +export function isDeleteActivityValid (activity: any) { // We don't really check objects return isBaseActivityValid(activity, 'Delete') && isObjectValid(activity.object) } -function isFollowActivityValid (activity: any) { +export function isFollowActivityValid (activity: any) { return isBaseActivityValid(activity, 'Follow') && isObjectValid(activity.object) } -function isAcceptActivityValid (activity: any) { +export function isAcceptActivityValid (activity: any) { return isBaseActivityValid(activity, 'Accept') } -function isRejectActivityValid (activity: any) { +export function isRejectActivityValid (activity: any) { return isBaseActivityValid(activity, 'Reject') } -function isUndoActivityValid (activity: any) { +export function isUndoActivityValid (activity: any) { return isBaseActivityValid(activity, 'Undo') && ( isFollowActivityValid(activity.object) || @@ -131,21 +135,14 @@ function isUndoActivityValid (activity: any) { ) } -// --------------------------------------------------------------------------- - -export { - isRootActivityValid, - isActivityValid, - isFlagActivityValid, - isLikeActivityValid, - isDislikeActivityValid, - isAnnounceActivityValid, - isViewActivityValid, - isCreateActivityValid, - isUpdateActivityValid, - isDeleteActivityValid, - isFollowActivityValid, - isAcceptActivityValid, - isRejectActivityValid, - isUndoActivityValid +export function isApproveReplyActivityValid (activity: any) { + return isBaseActivityValid(activity, 'ApproveReply') && + isActivityPubUrlValid(activity.object) && + isActivityPubUrlValid(activity.inReplyTo) +} + +export function isRejectReplyActivityValid (activity: any) { + return isBaseActivityValid(activity, 'RejectReply') && + isActivityPubUrlValid(activity.object) && + isActivityPubUrlValid(activity.inReplyTo) } diff --git a/server/core/helpers/custom-validators/activitypub/video-comments.ts b/server/core/helpers/custom-validators/activitypub/video-comments.ts index 944b5e996..d000fcf44 100644 --- a/server/core/helpers/custom-validators/activitypub/video-comments.ts +++ b/server/core/helpers/custom-validators/activitypub/video-comments.ts @@ -2,8 +2,9 @@ import { hasAPPublic } from '@server/helpers/activity-pub-utils.js' import validator from 'validator' import { exists, isArray, isDateValid } from '../misc.js' import { isActivityPubUrlValid } from './misc.js' +import { ActivityTombstoneObject, VideoCommentObject } from '@peertube/peertube-models' -function sanitizeAndCheckVideoCommentObject (comment: any) { +function sanitizeAndCheckVideoCommentObject (comment: VideoCommentObject | ActivityTombstoneObject) { if (!comment) return false if (!isCommentTypeValid(comment)) return false @@ -23,6 +24,7 @@ function sanitizeAndCheckVideoCommentObject (comment: any) { isDateValid(comment.published) && isActivityPubUrlValid(comment.url) && isArray(comment.to) && + (!exists(comment.replyApproval) || isActivityPubUrlValid(comment.replyApproval)) && (hasAPPublic(comment.to) || hasAPPublic(comment.cc)) // Only accept public comments } diff --git a/server/core/helpers/custom-validators/activitypub/videos.ts b/server/core/helpers/custom-validators/activitypub/videos.ts index 5561b94d8..255d051d7 100644 --- a/server/core/helpers/custom-validators/activitypub/videos.ts +++ b/server/core/helpers/custom-validators/activitypub/videos.ts @@ -1,18 +1,20 @@ -import validator from 'validator' import { ActivityPubStoryboard, ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject, LiveVideoLatencyMode, + VideoCommentPolicy, VideoObject, VideoState } from '@peertube/peertube-models' import { logger } from '@server/helpers/logger.js' +import validator from 'validator' import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js' import { peertubeTruncate } from '../../core-utils.js' import { isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc.js' import { isLiveLatencyModeValid } from '../video-lives.js' import { + isVideoCommentsPolicyValid, isVideoDescriptionValid, isVideoDurationValid, isVideoNameValid, @@ -66,12 +68,21 @@ function sanitizeAndCheckVideoTorrentObject (video: VideoObject) { if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true - if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false if (!isBooleanValid(video.permanentLive)) video.permanentLive = false if (!isLiveLatencyModeValid(video.latencyMode)) video.latencyMode = LiveVideoLatencyMode.DEFAULT + if (video.commentsPolicy) { + if (!isVideoCommentsPolicyValid(video.commentsPolicy)) { + video.commentsPolicy = VideoCommentPolicy.DISABLED + } + } else if (video.commentsEnabled === true) { // Fallback to deprecated attribute + video.commentsPolicy = VideoCommentPolicy.ENABLED + } else { + video.commentsPolicy = VideoCommentPolicy.DISABLED + } + return isActivityPubUrlValid(video.id) && isVideoNameValid(video.name) && isActivityPubVideoDurationValid(video.duration) && @@ -138,12 +149,12 @@ function isAPVideoTrackerUrlObject (url: any): url is ActivityTrackerUrlObject { // --------------------------------------------------------------------------- export { - sanitizeAndCheckVideoTorrentUpdateActivity, - isRemoteStringIdentifierValid, - sanitizeAndCheckVideoTorrentObject, - isRemoteVideoUrlValid, isAPVideoFileUrlMetadataObject, - isAPVideoTrackerUrlObject + isAPVideoTrackerUrlObject, + isRemoteStringIdentifierValid, + isRemoteVideoUrlValid, + sanitizeAndCheckVideoTorrentObject, + sanitizeAndCheckVideoTorrentUpdateActivity } // --------------------------------------------------------------------------- diff --git a/server/core/helpers/custom-validators/videos.ts b/server/core/helpers/custom-validators/videos.ts index 0c916a0ea..b7959202f 100644 --- a/server/core/helpers/custom-validators/videos.ts +++ b/server/core/helpers/custom-validators/videos.ts @@ -1,12 +1,13 @@ +import { HttpStatusCode, VideoIncludeType, VideoPrivacy, VideoPrivacyType, VideoRateType } from '@peertube/peertube-models' +import { getVideoWithAttributes } from '@server/helpers/video.js' import { Request, Response, UploadFilesForCheck } from 'express' import { decode as magnetUriDecode } from 'magnet-uri' import validator from 'validator' -import { HttpStatusCode, VideoIncludeType, VideoPrivacy, VideoPrivacyType, VideoRateType } from '@peertube/peertube-models' -import { getVideoWithAttributes } from '@server/helpers/video.js' import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_CATEGORIES, + VIDEO_COMMENTS_POLICY, VIDEO_LICENCES, VIDEO_LIVE, VIDEO_PRIVACIES, @@ -46,6 +47,10 @@ export function isVideoDescriptionValid (value: string) { return value === null || (exists(value) && validator.default.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.DESCRIPTION)) } +export function isVideoCommentsPolicyValid (value: any) { + return value === null || VIDEO_COMMENTS_POLICY[value] !== undefined +} + export function isVideoSupportValid (value: string) { return value === null || (exists(value) && validator.default.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.SUPPORT)) } diff --git a/server/core/helpers/custom-validators/watched-words.ts b/server/core/helpers/custom-validators/watched-words.ts new file mode 100644 index 000000000..fc2b770fe --- /dev/null +++ b/server/core/helpers/custom-validators/watched-words.ts @@ -0,0 +1,17 @@ +import validator from 'validator' +import { CONSTRAINTS_FIELDS } from '../../initializers/constants.js' +import { exists, isArray } from './misc.js' + +export function isWatchedWordListNameValid (listName: string) { + return exists(listName) && validator.default.isLength(listName, CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME) +} + +export function isWatchedWordValid (word: string) { + return exists(word) && validator.default.isLength(word, CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD) +} + +export function areWatchedWordsValid (words: string[]) { + return isArray(words) && + validator.default.isInt(words.length.toString(), CONSTRAINTS_FIELDS.WATCHED_WORDS.WORDS) && + words.every(word => isWatchedWordValid(word)) +} diff --git a/server/core/helpers/query.ts b/server/core/helpers/query.ts index 881046b5f..28cab9c21 100644 --- a/server/core/helpers/query.ts +++ b/server/core/helpers/query.ts @@ -26,7 +26,8 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) { 'hasWebtorrentFiles', // TODO: Remove in v7 'hasWebVideoFiles', 'search', - 'excludeAlreadyWatched' + 'excludeAlreadyWatched', + 'autoTagOneOf' ]) } diff --git a/server/core/helpers/regexp.ts b/server/core/helpers/regexp.ts index 257054cea..ca869e280 100644 --- a/server/core/helpers/regexp.ts +++ b/server/core/helpers/regexp.ts @@ -1,5 +1,5 @@ // Thanks to https://regex101.com -function regexpCapture (str: string, regex: RegExp, maxIterations = 100) { +export function regexpCapture (str: string, regex: RegExp, maxIterations = 100) { const result: RegExpExecArray[] = [] let m: RegExpExecArray let i = 0 @@ -17,6 +17,20 @@ function regexpCapture (str: string, regex: RegExp, maxIterations = 100) { return result } -export { - regexpCapture +export function wordsToRegExp (words: string[]) { + if (words.length === 0) throw new Error('Need words with at least one element') + + const innerRegex = words + .map(word => escapeForRegex(word.trim())) + .join('|') + + return new RegExp(`(?:\\P{L}|^)(?:${innerRegex})(?=\\P{L}|$)`, 'iu') +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +function escapeForRegex (value: string) { + return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') } diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts index 58926ae32..b0d218b8a 100644 --- a/server/core/initializers/checker-before-init.ts +++ b/server/core/initializers/checker-before-init.ts @@ -48,7 +48,7 @@ function checkMissedConfig () { 'auto_blacklist.videos.of_users.enabled', 'trending.videos.interval_days', 'client.videos.miniature.display_author_avatar', 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth', - 'defaults.publish.download_enabled', 'defaults.publish.comments_enabled', 'defaults.publish.privacy', 'defaults.publish.licence', + 'defaults.publish.download_enabled', 'defaults.publish.comments_policy', 'defaults.publish.privacy', 'defaults.publish.licence', 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', 'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt', 'services.twitter.username', diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index c6914a7e7..be8e77910 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'path' import { BroadcastMessageLevel, NSFWPolicyType, + VideoCommentPolicyType, VideoPrivacyType, VideoRedundancyConfigFilter, VideosRedundancyStrategy @@ -92,7 +93,7 @@ const CONFIG = { DEFAULTS: { PUBLISH: { DOWNLOAD_ENABLED: config.get<boolean>('defaults.publish.download_enabled'), - COMMENTS_ENABLED: config.get<boolean>('defaults.publish.comments_enabled'), + COMMENTS_POLICY: config.get<VideoCommentPolicyType>('defaults.publish.comments_policy'), PRIVACY: config.get<VideoPrivacyType>('defaults.publish.privacy'), LICENCE: config.get<number>('defaults.publish.licence') }, diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 4b6eecadc..006febe3a 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -18,6 +18,8 @@ import { UserRegistrationStateType, VideoChannelSyncState, VideoChannelSyncStateType, + VideoCommentPolicy, + VideoCommentPolicyType, VideoImportState, VideoImportStateType, VideoPlaylistPrivacy, @@ -134,6 +136,8 @@ const SORTABLE_COLUMNS = { ACCOUNTS_BLOCKLIST: [ 'createdAt' ], SERVERS_BLOCKLIST: [ 'createdAt' ], + WATCHED_WORDS_LISTS: [ 'createdAt', 'updatedAt', 'listName' ], + USER_NOTIFICATIONS: [ 'createdAt', 'read' ], VIDEO_PLAYLISTS: [ 'name', 'displayName', 'createdAt', 'updatedAt' ], @@ -498,6 +502,11 @@ const CONSTRAINTS_FIELDS = { }, VIDEO_CHAPTERS: { TITLE: { min: 1, max: 100 } // Length + }, + WATCHED_WORDS: { + LIST_NAME: { min: 1, max: 100 }, // Length + WORDS: { min: 1, max: 500 }, // Number of total words + WORD: { min: 1, max: 100 } // Length } } @@ -663,6 +672,12 @@ const USER_IMPORT_STATES: { [ id in UserImportStateType ]: string } = { [UserImportState.ERRORED]: 'Failed' } +const VIDEO_COMMENTS_POLICY: { [ id in VideoCommentPolicyType ]: string } = { + [VideoCommentPolicy.DISABLED]: 'Disabled', + [VideoCommentPolicy.ENABLED]: 'Enabled', + [VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval' +} + const MIMETYPES = { AUDIO: { MIMETYPE_EXT: { @@ -973,6 +988,10 @@ const LRU_CACHE = { MAX_SIZE: 100_000, TTL: parseDurationToMs('8 hours') }, + WATCHED_WORDS_REGEX: { + MAX_SIZE: 100, + TTL: parseDurationToMs('24 hours') + }, TRACKER_IPS: { MAX_SIZE: 100_000 } @@ -1243,6 +1262,7 @@ export { ACCEPT_HEADERS, BCRYPT_SALT_SIZE, TRACKER_RATE_LIMITS, + VIDEO_COMMENTS_POLICY, FILES_CACHE, LOG_FILENAME, CONSTRAINTS_FIELDS, diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index b4274be46..14ba74bdc 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -1,19 +1,22 @@ -import pg from 'pg' -import { QueryTypes, Transaction } from 'sequelize' -import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' import { isTestOrDevInstance } from '@peertube/peertube-node-utils' import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js' +import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js' +import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js' +import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js' import { RunnerJobModel } from '@server/models/runner/runner-job.js' import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token.js' import { RunnerModel } from '@server/models/runner/runner.js' import { TrackerModel } from '@server/models/server/tracker.js' import { VideoTrackerModel } from '@server/models/server/video-tracker.js' +import { UserExportModel } from '@server/models/user/user-export.js' +import { UserImportModel } from '@server/models/user/user-import.js' import { UserNotificationModel } from '@server/models/user/user-notification.js' import { UserRegistrationModel } from '@server/models/user/user-registration.js' import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js' import { UserModel } from '@server/models/user/user.js' import { StoryboardModel } from '@server/models/video/storyboard.js' import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' +import { VideoChapterModel } from '@server/models/video/video-chapter.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js' import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js' @@ -21,6 +24,10 @@ import { VideoPasswordModel } from '@server/models/video/video-password.js' import { VideoSourceModel } from '@server/models/video/video-source.js' import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section.js' import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js' +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' +import pg from 'pg' +import { QueryTypes, Transaction } from 'sequelize' +import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' import { logger } from '../helpers/logger.js' import { AbuseMessageModel } from '../models/abuse/abuse-message.js' import { AbuseModel } from '../models/abuse/abuse.js' @@ -59,9 +66,7 @@ import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' import { VideoViewModel } from '../models/view/video-view.js' import { CONFIG } from './config.js' -import { VideoChapterModel } from '@server/models/video/video-chapter.js' -import { UserExportModel } from '@server/models/user/user-export.js' -import { UserImportModel } from '@server/models/user/user-import.js' +import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -176,7 +181,12 @@ async function initDatabaseModels (silent: boolean) { RunnerModel, RunnerJobModel, StoryboardModel, - UserExportModel + UserExportModel, + VideoAutomaticTagModel, + CommentAutomaticTagModel, + AutomaticTagModel, + WatchedWordsListModel, + AccountAutomaticTagPolicyModel ]) // Check extensions exist in the database @@ -191,9 +201,7 @@ async function initDatabaseModels (silent: boolean) { // --------------------------------------------------------------------------- export { - initDatabaseModels, - checkDatabaseConnectionOrDie, - sequelizeTypescript + checkDatabaseConnectionOrDie, initDatabaseModels, sequelizeTypescript } // --------------------------------------------------------------------------- diff --git a/server/core/initializers/migrations/0840-auto-tags.ts b/server/core/initializers/migrations/0840-auto-tags.ts new file mode 100644 index 000000000..d51033635 --- /dev/null +++ b/server/core/initializers/migrations/0840-auto-tags.ts @@ -0,0 +1,122 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise<void> { + const { transaction } = utils + + { + const query = `CREATE TABLE IF NOT EXISTS "automaticTag" ("id" SERIAL , "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("id"));` + + await utils.sequelize.query(query, { transaction }) + } + + { + const query = ` +CREATE TABLE IF NOT EXISTS "videoAutomaticTag"( + "videoId" integer REFERENCES "video"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "automaticTagId" integer NOT NULL REFERENCES "automaticTag"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "accountId" integer REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("videoId", "automaticTagId", "accountId") +);` + + await utils.sequelize.query(query, { transaction }) + } + + { + const query = ` +CREATE TABLE IF NOT EXISTS "commentAutomaticTag"( + "commentId" integer REFERENCES "videoComment"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "automaticTagId" integer NOT NULL REFERENCES "automaticTag"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "accountId" integer REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("commentId", "automaticTagId", "accountId") +);` + + await utils.sequelize.query(query, { transaction }) + } + + { + const query = ` +CREATE TABLE IF NOT EXISTS "watchedWordsList"( + "id" serial, + "listName" varchar(255) NOT NULL, + "words" varchar(255)[] NOT NULL, + "accountId" integer NOT NULL REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("id") +);` + + await utils.sequelize.query(query, { transaction }) + } + + { + const query = ` +CREATE TABLE IF NOT EXISTS "accountAutomaticTagPolicy"( + "id" serial, + "policy" integer, + "accountId" integer NOT NULL REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "automaticTagId" integer NOT NULL REFERENCES "automaticTag"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("id") +);` + + await utils.sequelize.query(query, { transaction }) + } + + { + await utils.queryInterface.addColumn('video', 'commentsPolicy', { + type: Sequelize.INTEGER, + defaultValue: 1, // ENABLED + allowNull: false + }, { transaction }) + + const query = `UPDATE "video" SET "commentsPolicy" = 2 WHERE "commentsEnabled" IS FALSE` // Disabled + await utils.sequelize.query(query, { transaction }) + + await utils.queryInterface.changeColumn('video', 'commentsPolicy', { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: false + }, { transaction }) + + await utils.queryInterface.removeColumn('video', 'commentsEnabled', { transaction }) + } + + { + await utils.queryInterface.addColumn('videoComment', 'heldForReview', { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }, { transaction }) + + await utils.queryInterface.changeColumn('videoComment', 'heldForReview', { + type: Sequelize.BOOLEAN, + defaultValue: null, + allowNull: false + }, { transaction }) + } + + { + await utils.queryInterface.addColumn('videoComment', 'replyApproval', { + type: Sequelize.STRING, + defaultValue: null, + allowNull: true + }, { transaction }) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + down, up +} diff --git a/server/core/lib/activitypub/process/process-create.ts b/server/core/lib/activitypub/process/process-create.ts index 5f031b7fa..40a3fe15e 100644 --- a/server/core/lib/activitypub/process/process-create.ts +++ b/server/core/lib/activitypub/process/process-create.ts @@ -1,6 +1,3 @@ -import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js' -import { isRedundancyAccepted } from '@server/lib/redundancy.js' -import { VideoModel } from '@server/models/video/video.js' import { AbuseObject, ActivityCreate, @@ -12,6 +9,10 @@ import { VideoObject, WatchActionObject } from '@peertube/peertube-models' +import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js' +import { isRedundancyAccepted } from '@server/lib/redundancy.js' +import { VideoCommentModel } from '@server/models/video/video-comment.js' +import { VideoModel } from '@server/models/video/video.js' import { retryTransactionWrapper } from '../../../helpers/database-utils.js' import { logger } from '../../../helpers/logger.js' import { sequelizeTypescript } from '../../../initializers/database.js' @@ -22,6 +23,7 @@ import { fetchAPObjectIfNeeded } from '../activity.js' import { createOrUpdateCacheFile } from '../cache-file.js' import { createOrUpdateLocalVideoViewer } from '../local-video-viewer.js' import { createOrUpdateVideoPlaylist } from '../playlists/index.js' +import { sendReplyApproval } from '../send/send-reply-approval.js' import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js' import { resolveThread } from '../video-comments.js' import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js' @@ -42,7 +44,7 @@ async function processCreateActivity (options: APProcessorOptions<ActivityCreate // Comments will be fetched from videos if (options.fromFetch) return - return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, notify) + return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, options.fromFetch) } if (activityType === 'WatchAction') { @@ -118,8 +120,10 @@ async function processCreateVideoComment ( activity: ActivityCreate<VideoCommentObject | string>, commentObject: VideoCommentObject, byActor: MActorSignature, - notify: boolean + fromFetch: false ) { + if (fromFetch) throw new Error('Processing create video comment from fetch is not supported') + const byAccount = byActor.Account if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url) @@ -146,12 +150,24 @@ async function processCreateVideoComment ( // Try to not forward unwanted comments on our videos if (video.isOwned()) { + if (!canVideoBeFederated(video)) { + logger.info('Skip comment forward on non federated video' + video.url) + return + } + if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) { logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url) return } - if (created === true) { + // New non-moderated comment -> auto approve reply + if (comment.heldForReview === false && created) { + const reply = await VideoCommentModel.loadById(comment.inReplyToCommentId) + sendReplyApproval(Object.assign(comment, { InReplyToVideoComment: reply }), 'ApproveReply') + } + + // New comment or re-sent after an approval -> forward comment + if (comment.heldForReview === false && (created || commentObject.replyApproval)) { // Don't resend the activity to the sender const exceptions = [ byActor ] @@ -159,7 +175,7 @@ async function processCreateVideoComment ( } } - if (created && notify) Notifier.Instance.notifyOnNewComment(comment) + if (created) Notifier.Instance.notifyOnNewComment(comment) } async function processCreatePlaylist ( diff --git a/server/core/lib/activitypub/process/process-delete.ts b/server/core/lib/activitypub/process/process-delete.ts index 6c4f3099a..46ca0d73e 100644 --- a/server/core/lib/activitypub/process/process-delete.ts +++ b/server/core/lib/activitypub/process/process-delete.ts @@ -44,7 +44,7 @@ async function processDeleteActivity (options: APProcessorOptions<ActivityDelete } { - const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(objectUrl) + const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(objectUrl) if (videoCommentInstance) { return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity) } diff --git a/server/core/lib/activitypub/process/process-flag.ts b/server/core/lib/activitypub/process/process-flag.ts index 9aba1c896..45cfa7024 100644 --- a/server/core/lib/activitypub/process/process-flag.ts +++ b/server/core/lib/activitypub/process/process-flag.ts @@ -51,7 +51,7 @@ async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature) let videoComment: MCommentOwnerVideo let flaggedAccount: MAccountDefault - if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri, t) + if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(uri, t) if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri, t) if (!video && !videoComment && !flaggedAccount) { diff --git a/server/core/lib/activitypub/process/process-reply-approval.ts b/server/core/lib/activitypub/process/process-reply-approval.ts new file mode 100644 index 000000000..6755168ca --- /dev/null +++ b/server/core/lib/activitypub/process/process-reply-approval.ts @@ -0,0 +1,45 @@ +import { ActivityApproveReply, ActivityRejectReply, ActivityType } from '@peertube/peertube-models' +import { VideoCommentModel } from '@server/models/video/video-comment.js' +import { sequelizeTypescript } from '../../../initializers/database.js' +import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' +import { MCommentOwnerVideoReply } from '../../../types/models/index.js' +import { sendCreateVideoCommentIfNeeded } from '../send/send-create.js' + +export function processReplyApprovalFactory (type: Extract<ActivityType, 'ApproveReply' | 'RejectReply'>) { + return async (options: APProcessorOptions<ActivityApproveReply | ActivityRejectReply>) => { + if (type === 'RejectReply') return // Not yet implemented + + const { activity, byActor } = options + const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(activity.object) + + if (!comment || comment.isDeleted()) { + throw new Error(`Cannot process reply approval on comment ${comment.url} that doesn't exist`) + } + + if (comment.isOwned() !== true) { + throw new Error(`Cannot process reply approval on non-owned comment ${comment.url}`) + } + + if (byActor.id !== comment.Video.VideoChannel.Account.Actor.id) { + throw new Error(`Cannot process reply approval on ${comment.url} by non video owner`) + } + + return processApproveReply(activity.id, comment) + } +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function processApproveReply (replyApproval: string, comment: MCommentOwnerVideoReply) { + if (comment.heldForReview === false || comment.replyApproval === replyApproval) return + + return sequelizeTypescript.transaction(async t => { + comment.heldForReview = false + comment.replyApproval = replyApproval + await comment.save({ transaction: t }) + + await sendCreateVideoCommentIfNeeded(comment, t) + }) +} diff --git a/server/core/lib/activitypub/process/process.ts b/server/core/lib/activitypub/process/process.ts index 5e187cecb..1db0e6f06 100644 --- a/server/core/lib/activitypub/process/process.ts +++ b/server/core/lib/activitypub/process/process.ts @@ -1,5 +1,5 @@ -import { StatsManager } from '@server/lib/stat-manager.js' import { Activity, ActivityType } from '@peertube/peertube-models' +import { StatsManager } from '@server/lib/stat-manager.js' import { logger } from '../../../helpers/logger.js' import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' import { MActorDefault, MActorSignature } from '../../../types/models/index.js' @@ -15,6 +15,7 @@ import { processFlagActivity } from './process-flag.js' import { processFollowActivity } from './process-follow.js' import { processLikeActivity } from './process-like.js' import { processRejectActivity } from './process-reject.js' +import { processReplyApprovalFactory } from './process-reply-approval.js' import { processUndoActivity } from './process-undo.js' import { processUpdateActivity } from './process-update.js' import { processViewActivity } from './process-view.js' @@ -31,7 +32,9 @@ const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Act Like: processLikeActivity, Dislike: processDislikeActivity, Flag: processFlagActivity, - View: processViewActivity + View: processViewActivity, + ApproveReply: processReplyApprovalFactory('ApproveReply'), + RejectReply: processReplyApprovalFactory('RejectReply') } export async function processActivities ( diff --git a/server/core/lib/activitypub/send/http.ts b/server/core/lib/activitypub/send/http.ts index 09e1c8eef..5771db9f5 100644 --- a/server/core/lib/activitypub/send/http.ts +++ b/server/core/lib/activitypub/send/http.ts @@ -6,6 +6,7 @@ import { MActor } from '@server/types/models/index.js' import { getContextFilter } from '../context.js' import { buildDigestFromWorker, signJsonLDObjectFromWorker } from '@server/lib/worker/parent-process.js' import { signAndContextify } from '@server/helpers/activity-pub-utils.js' +import { logger } from '@server/helpers/logger.js' type Payload <T> = { body: T, contextType: ContextType, signatureActorId?: number } @@ -18,13 +19,17 @@ export async function computeBody <T> ( const actorSignature = await ActorModel.load(payload.signatureActorId) if (!actorSignature) throw new Error('Unknown signature actor id.') - body = await signAndContextify({ - byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey }, - data: payload.body, - contextType: payload.contextType, - contextFilter: getContextFilter(), - signerFunction: signJsonLDObjectFromWorker - }) + try { + body = await signAndContextify({ + byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey }, + data: payload.body, + contextType: payload.contextType, + contextFilter: getContextFilter(), + signerFunction: signJsonLDObjectFromWorker + }) + } catch (err) { + logger.error('Cannot sign and contextify body', { body, err }) + } } return body diff --git a/server/core/lib/activitypub/send/index.ts b/server/core/lib/activitypub/send/index.ts index 8711cb17b..efeb9e25a 100644 --- a/server/core/lib/activitypub/send/index.ts +++ b/server/core/lib/activitypub/send/index.ts @@ -6,5 +6,6 @@ export * from './send-delete.js' export * from './send-follow.js' export * from './send-like.js' export * from './send-reject.js' +export * from './send-reply-approval.js' export * from './send-undo.js' export * from './send-update.js' diff --git a/server/core/lib/activitypub/send/send-create.ts b/server/core/lib/activitypub/send/send-create.ts index edffe7fca..f1e132634 100644 --- a/server/core/lib/activitypub/send/send-create.ts +++ b/server/core/lib/activitypub/send/send-create.ts @@ -15,10 +15,9 @@ import { logger, loggerTagsFactory } from '../../../helpers/logger.js' import { VideoCommentModel } from '../../../models/video/video-comment.js' import { MActorLight, - MCommentOwnerVideo, + MCommentOwnerVideoReply, MLocalVideoViewerWithWatchSections, - MVideoAP, - MVideoAccountLight, + MVideoAP, MVideoAccountLight, MVideoPlaylistFull, MVideoRedundancyFileVideo, MVideoRedundancyStreamingPlaylistVideo @@ -111,7 +110,7 @@ export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, tra }) } -export async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) { +export async function sendCreateVideoCommentIfNeeded (comment: MCommentOwnerVideoReply, transaction: Transaction) { const isOrigin = comment.Video.isOwned() if (isOrigin) { @@ -121,6 +120,11 @@ export async function sendCreateVideoComment (comment: MCommentOwnerVideo, trans logger.debug(`Do not send comment ${comment.url} on a video that cannot be federated`) return undefined } + + if (comment.heldForReview) { + logger.debug(`Do not send comment ${comment.url} that requires approval`) + return undefined + } } logger.info('Creating job to send comment %s.', comment.url) @@ -128,14 +132,14 @@ export async function sendCreateVideoComment (comment: MCommentOwnerVideo, trans const byActor = comment.Account.Actor const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction) - const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, transaction) + const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction }) const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction) // Add the actor that commented too actorsInvolvedInComment.push(byActor) - const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted()) + const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview) .map(c => c.Account.Actor) let audience: ActivityAudience diff --git a/server/core/lib/activitypub/send/send-delete.ts b/server/core/lib/activitypub/send/send-delete.ts index 3bf5ae75a..eee777141 100644 --- a/server/core/lib/activitypub/send/send-delete.ts +++ b/server/core/lib/activitypub/send/send-delete.ts @@ -63,8 +63,8 @@ async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, transac ? videoComment.Account.Actor : videoAccount.Actor - const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, transaction) - const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted()) + const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment, transaction }) + const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview) const actorsInvolvedInComment = await getActorsInvolvedInVideo(videoComment.Video, transaction) actorsInvolvedInComment.push(byActor) // Add the actor that commented the video diff --git a/server/core/lib/activitypub/send/send-reply-approval.ts b/server/core/lib/activitypub/send/send-reply-approval.ts new file mode 100644 index 000000000..a2e7b067e --- /dev/null +++ b/server/core/lib/activitypub/send/send-reply-approval.ts @@ -0,0 +1,36 @@ +import { ActivityApproveReply, ActivityRejectReply } from '@peertube/peertube-models' +import { logger } from '../../../helpers/logger.js' +import { MCommentOwnerVideoReply } from '../../../types/models/index.js' +import { getLocalApproveReplyActivityPubUrl } from '../url.js' +import { unicastTo } from './shared/send-utils.js' + +// We can support type: 'RejectReply' in the future +export function sendReplyApproval (comment: MCommentOwnerVideoReply, type: 'ApproveReply') { + logger.info('Creating job to approve reply %s.', comment.url) + + const data = buildApprovalActivity({ comment, type }) + + return unicastTo({ + data, + byActor: comment.Video.VideoChannel.Account.Actor, + toActorUrl: comment.Account.Actor.inboxUrl, + contextType: type + }) +} + +export function buildApprovalActivity (options: { + comment: MCommentOwnerVideoReply + type: 'ApproveReply' +}): ActivityApproveReply | ActivityRejectReply { + const { comment, type } = options + + return { + type, + id: type === 'ApproveReply' + ? getLocalApproveReplyActivityPubUrl(comment.Video, comment) + : undefined, // 'RejectReply' Not implemented yet + actor: comment.Video.VideoChannel.Account.Actor.url, + inReplyTo: comment.InReplyToVideoComment?.url ?? comment.Video.url, + object: comment.url + } +} diff --git a/server/core/lib/activitypub/send/shared/audience-utils.ts b/server/core/lib/activitypub/send/shared/audience-utils.ts index be3fd2900..1e2dbd50d 100644 --- a/server/core/lib/activitypub/send/shared/audience-utils.ts +++ b/server/core/lib/activitypub/send/shared/audience-utils.ts @@ -45,7 +45,7 @@ export function getVideoCommentAudience ( export function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience { return { - to: [ getAPPublicValue() ].concat(actorsInvolvedInObject.map(a => a.followersUrl)), + to: [ getAPPublicValue() as string ].concat(actorsInvolvedInObject.map(a => a.followersUrl)), cc: [] } } diff --git a/server/core/lib/activitypub/url.ts b/server/core/lib/activitypub/url.ts index 4bbd1c573..9f428029b 100644 --- a/server/core/lib/activitypub/url.ts +++ b/server/core/lib/activitypub/url.ts @@ -6,123 +6,126 @@ import { MActorFollow, MActorId, MActorUrl, - MCommentId, - MLocalVideoViewer, + MCommentId, MLocalVideoViewer, MVideoId, MVideoPlaylistElement, - MVideoUrl, MVideoUUID, + MVideoUrl, MVideoWithHost } from '../../types/models/index.js' import { MVideoFileVideoUUID } from '../../types/models/video/video-file.js' import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist.js' import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist.js' -function getLocalVideoActivityPubUrl (video: MVideoUUID) { +export function getLocalVideoActivityPubUrl (video: MVideoUUID) { return WEBSERVER.URL + '/videos/watch/' + video.uuid } -function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) { +export function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) { return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid } -function getLocalVideoPlaylistElementActivityPubUrl (videoPlaylist: MVideoPlaylistUUID, videoPlaylistElement: MVideoPlaylistElement) { - return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/videos/' + videoPlaylistElement.id +export function getLocalVideoPlaylistElementActivityPubUrl (playlist: MVideoPlaylistUUID, element: MVideoPlaylistElement) { + return WEBSERVER.URL + '/video-playlists/' + playlist.uuid + '/videos/' + element.id } -function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) { +export function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) { const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : '' return `${WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}` } -function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) { +export function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) { return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}` } -function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) { +export function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) { return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id } -function getLocalVideoChannelActivityPubUrl (videoChannelName: string) { +export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) { return WEBSERVER.URL + '/video-channels/' + videoChannelName } -function getLocalAccountActivityPubUrl (accountName: string) { +export function getLocalAccountActivityPubUrl (accountName: string) { return WEBSERVER.URL + '/accounts/' + accountName } -function getLocalAbuseActivityPubUrl (abuse: MAbuseId) { +export function getLocalAbuseActivityPubUrl (abuse: MAbuseId) { return WEBSERVER.URL + '/admin/abuses/' + abuse.id } -function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) { +export function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) { return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier } -function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) { +export function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) { return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid } -function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { +export function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { return byActor.url + '/likes/' + video.id } -function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { +export function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { return byActor.url + '/dislikes/' + video.id } -function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) { +export function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) { return video.url + '/announces' } -function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) { +export function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) { return video.url + '/comments' } -function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) { +export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) { return video.url + '/chapters' } -function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { +export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { return video.url + '/likes' } -function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) { +export function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) { return video.url + '/dislikes' } -function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) { +export function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) { return follower.url + '/follows/' + following.id } -function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) { +export function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) { return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id } -function getLocalActorFollowRejectActivityPubUrl () { +export function getLocalActorFollowRejectActivityPubUrl () { return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString() } -function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) { +export function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) { return video.url + '/announces/' + byActor.id } -function getDeleteActivityPubUrl (originalUrl: string) { +export function getDeleteActivityPubUrl (originalUrl: string) { return originalUrl + '/delete' } -function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) { +export function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) { return originalUrl + '/updates/' + updatedAt } -function getUndoActivityPubUrl (originalUrl: string) { +export function getUndoActivityPubUrl (originalUrl: string) { return originalUrl + '/undo' } +export function getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: MCommentId) { + return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply' +} + // --------------------------------------------------------------------------- -function getAbuseTargetUrl (abuse: MAbuseFull) { +export function getAbuseTargetUrl (abuse: MAbuseFull) { return abuse.VideoAbuse?.Video?.url || abuse.VideoCommentAbuse?.VideoComment?.url || abuse.FlaggedAccount.Actor.url @@ -130,7 +133,7 @@ function getAbuseTargetUrl (abuse: MAbuseFull) { // --------------------------------------------------------------------------- -function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) { +export function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) { if (!scheme) scheme = REMOTE_SCHEME.HTTP const host = video.VideoChannel.Actor.Server.host @@ -140,43 +143,9 @@ function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) { // --------------------------------------------------------------------------- -function checkUrlsSameHost (url1: string, url2: string) { +export function checkUrlsSameHost (url1: string, url2: string) { const idHost = new URL(url1).host const actorHost = new URL(url2).host return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase() } - -// --------------------------------------------------------------------------- - -export { - getLocalVideoActivityPubUrl, - getLocalVideoPlaylistActivityPubUrl, - getLocalVideoPlaylistElementActivityPubUrl, - getLocalVideoCacheFileActivityPubUrl, - getLocalVideoCacheStreamingPlaylistActivityPubUrl, - getLocalVideoCommentActivityPubUrl, - getLocalVideoChannelActivityPubUrl, - getLocalAccountActivityPubUrl, - getLocalAbuseActivityPubUrl, - getLocalActorFollowActivityPubUrl, - getLocalActorFollowAcceptActivityPubUrl, - getLocalVideoAnnounceActivityPubUrl, - getUpdateActivityPubUrl, - getUndoActivityPubUrl, - getVideoLikeActivityPubUrlByLocalActor, - getLocalVideoViewActivityPubUrl, - getVideoDislikeActivityPubUrlByLocalActor, - getLocalActorFollowRejectActivityPubUrl, - getDeleteActivityPubUrl, - getLocalVideoSharesActivityPubUrl, - getLocalVideoCommentsActivityPubUrl, - getLocalVideoChaptersActivityPubUrl, - getLocalVideoLikesActivityPubUrl, - getLocalVideoDislikesActivityPubUrl, - getLocalVideoViewerActivityPubUrl, - - getAbuseTargetUrl, - checkUrlsSameHost, - buildRemoteUrl -} diff --git a/server/core/lib/activitypub/video-comments.ts b/server/core/lib/activitypub/video-comments.ts index f710f9b97..067f8e911 100644 --- a/server/core/lib/activitypub/video-comments.ts +++ b/server/core/lib/activitypub/video-comments.ts @@ -1,11 +1,21 @@ +import { VideoCommentPolicy } from '@peertube/peertube-models' import Bluebird from 'bluebird' import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments.js' import { logger } from '../../helpers/logger.js' import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js' import { VideoCommentModel } from '../../models/video/video-comment.js' -import { MComment, MCommentOwner, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../types/models/video/index.js' +import { + MComment, + MCommentOwner, + MCommentOwnerVideo, + MVideoAccountLight, + MVideoAccountLightBlacklistAllFiles +} from '../../types/models/video/index.js' +import { AutomaticTagger } from '../automatic-tags/automatic-tagger.js' +import { setAndSaveCommentAutomaticTags } from '../automatic-tags/automatic-tags.js' import { isRemoteVideoCommentAccepted } from '../moderation.js' import { Hooks } from '../plugins/hooks.js' +import { shouldCommentBeHeldForReview } from '../video-comment.js' import { fetchAP } from './activity.js' import { getOrCreateAPActor } from './actors/index.js' import { checkUrlsSameHost } from './url.js' @@ -19,7 +29,7 @@ type ResolveThreadParams = { } type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }> -async function addVideoComments (commentUrls: string[]) { +export async function addVideoComments (commentUrls: string[]) { return Bluebird.map(commentUrls, async commentUrl => { try { await resolveThread({ url: commentUrl, isVideo: false }) @@ -29,7 +39,7 @@ async function addVideoComments (commentUrls: string[]) { }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) } -async function resolveThread (params: ResolveThreadParams): ResolveThreadResult { +export async function resolveThread (params: ResolveThreadParams): ResolveThreadResult { const { url, isVideo } = params if (params.commentCreated === undefined) params.commentCreated = false @@ -54,24 +64,21 @@ async function resolveThread (params: ResolveThreadParams): ResolveThreadResult return resolveRemoteParentComment(params) } -export { - addVideoComments, - resolveThread -} - +// --------------------------------------------------------------------------- +// Private // --------------------------------------------------------------------------- async function resolveCommentFromDB (params: ResolveThreadParams) { const { url, comments, commentCreated } = params - const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url) + const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url) if (!commentFromDatabase) return undefined let parentComments = comments.concat([ commentFromDatabase ]) // Speed up things and resolve directly the thread if (commentFromDatabase.InReplyToVideoComment) { - const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC') + const data = await VideoCommentModel.listThreadParentComments({ comment: commentFromDatabase, order: 'DESC' }) parentComments = parentComments.concat(data) } @@ -84,6 +91,8 @@ async function resolveCommentFromDB (params: ResolveThreadParams) { }) } +// --------------------------------------------------------------------------- + async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { const { url, comments, commentCreated } = params @@ -96,6 +105,10 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { throw new Error('Cannot resolve thread of video that is not compatible with federation') } + if (video.commentsPolicy === VideoCommentPolicy.DISABLED) { + return undefined + } + let resultComment: MCommentOwnerVideo if (comments.length !== 0) { const firstReply = comments[comments.length - 1] as MCommentOwnerVideo @@ -109,8 +122,11 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { return undefined } + const firstReplyAutomaticTags = await getAutomaticTagsAndAssignReview(firstReply, video) comments[comments.length - 1] = await firstReply.save() + await setAndSaveCommentAutomaticTags({ comment: firstReply, automaticTags: firstReplyAutomaticTags }) + for (let i = comments.length - 2; i >= 0; i--) { const comment = comments[i] as MCommentOwnerVideo comment.originCommentId = firstReply.id @@ -123,7 +139,11 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { return undefined } + const automaticTags = await getAutomaticTagsAndAssignReview(comment, video) + comments[i] = await comment.save() + + await setAndSaveCommentAutomaticTags({ comment, automaticTags }) } resultComment = comments[0] as MCommentOwnerVideo @@ -132,6 +152,26 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { return { video, comment: resultComment, commentCreated } } +async function getAutomaticTagsAndAssignReview (comment: MComment, video: MVideoAccountLight) { + // Remote comment already exists in database or remote video -> we don't need to rebuild automatic tags + if (comment.id) return [] + + const ownerAccount = video.VideoChannel.Account + + const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ ownerAccount, text: comment.text }) + + // Third parties rely on origin, so if origin has the comment it's not held for review + if (video.isOwned() || comment.isOwned()) { + comment.heldForReview = await shouldCommentBeHeldForReview({ user: null, video, automaticTags }) + } else { + comment.heldForReview = false + } + + return automaticTags +} + +// --------------------------------------------------------------------------- + async function resolveRemoteParentComment (params: ResolveThreadParams) { const { url, comments } = params @@ -169,7 +209,11 @@ async function resolveRemoteParentComment (params: ResolveThreadParams) { originCommentId: null, createdAt: new Date(body.published), updatedAt: new Date(body.updated), - deletedAt: body.deleted ? new Date(body.deleted) : null + replyApproval: body.replyApproval, + + deletedAt: body.deleted + ? new Date(body.deleted) + : null }) as MCommentOwner comment.Account = actor ? actor.Account : null diff --git a/server/core/lib/activitypub/videos/shared/abstract-builder.ts b/server/core/lib/activitypub/videos/shared/abstract-builder.ts index 2c0ad99ac..82dba850f 100644 --- a/server/core/lib/activitypub/videos/shared/abstract-builder.ts +++ b/server/core/lib/activitypub/videos/shared/abstract-builder.ts @@ -1,4 +1,3 @@ -import { CreationAttributes, Transaction } from 'sequelize' import { ActivityTagObject, ThumbnailType, @@ -6,9 +5,14 @@ import { VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' +import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js' import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js' -import { logger, LoggerTagsFn } from '@server/helpers/logger.js' +import { LoggerTagsFn, logger } from '@server/helpers/logger.js' +import { sequelizeTypescript } from '@server/initializers/database.js' +import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js' +import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js' import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' +import { replaceChapters } from '@server/lib/video-chapters.js' import { setVideoTags } from '@server/lib/video.js' import { StoryboardModel } from '@server/models/video/storyboard.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js' @@ -18,11 +22,14 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin import { MStreamingPlaylistFiles, MStreamingPlaylistFilesVideo, + MVideo, MVideoCaption, MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js' +import { CreationAttributes, Transaction } from 'sequelize' +import { fetchAP } from '../../activity.js' import { findOwner, getOrCreateAPActor } from '../../actors/index.js' import { getCaptionAttributesFromObject, @@ -35,10 +42,6 @@ import { getThumbnailFromIcons } from './object-to-model-attributes.js' import { getTrackerUrls, setVideoTrackers } from './trackers.js' -import { fetchAP } from '../../activity.js' -import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js' -import { sequelizeTypescript } from '@server/initializers/database.js' -import { replaceChapters } from '@server/lib/video-chapters.js' export abstract class APVideoAbstractBuilder { protected abstract videoObject: VideoObject @@ -217,4 +220,17 @@ export abstract class APVideoAbstractBuilder { const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t)) playlistModel.VideoFiles = await Promise.all(upsertTasks) } + + protected async setAutomaticTags (options: { + video: MVideo + oldVideo?: Pick<MVideo, 'name' | 'description'> + transaction: Transaction + }) { + const { video, transaction, oldVideo } = options + + if (oldVideo && video.name === oldVideo.name && video.description === oldVideo.description) return + + const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction }) + await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction }) + } } diff --git a/server/core/lib/activitypub/videos/shared/creator.ts b/server/core/lib/activitypub/videos/shared/creator.ts index d387ff01e..676074343 100644 --- a/server/core/lib/activitypub/videos/shared/creator.ts +++ b/server/core/lib/activitypub/videos/shared/creator.ts @@ -1,10 +1,10 @@ +import { VideoObject } from '@peertube/peertube-models' import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js' import { sequelizeTypescript } from '@server/initializers/database.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js' import { VideoModel } from '@server/models/video/video.js' import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js' -import { VideoObject } from '@peertube/peertube-models' import { APVideoAbstractBuilder } from './abstract-builder.js' import { getVideoAttributesFromObject } from './object-to-model-attributes.js' @@ -41,6 +41,8 @@ export class APVideoCreator extends APVideoAbstractBuilder { await this.insertOrReplaceLive(videoCreated, t) await this.insertOrReplaceStoryboard(videoCreated, t) + await this.setAutomaticTags({ video: videoCreated, transaction: t }) + // We added a video in this channel, set it as updated await channel.setAsUpdated(t) diff --git a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts index ae9f8a651..75685376b 100644 --- a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts @@ -219,7 +219,9 @@ export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObj description, support, nsfw: videoObject.sensitive, - commentsEnabled: videoObject.commentsEnabled, + + commentsPolicy: videoObject.commentsPolicy, + downloadEnabled: videoObject.downloadEnabled, waitTranscoding: videoObject.waitTranscoding, isLive: videoObject.isLiveBroadcast, diff --git a/server/core/lib/activitypub/videos/updater.ts b/server/core/lib/activitypub/videos/updater.ts index e722744bd..3c7058644 100644 --- a/server/core/lib/activitypub/videos/updater.ts +++ b/server/core/lib/activitypub/videos/updater.ts @@ -52,6 +52,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder { this.checkChannelUpdateOrThrow(channelActor) const oldState = this.video.state + const oldVideo = { name: this.video.name, description: this.video.description } + const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo) await runInReadCommittedTransaction(async t => { @@ -63,6 +65,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder { runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)), runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)), runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)), + runInReadCommittedTransaction(t => this.setAutomaticTags({ video: videoUpdated, transaction: t, oldVideo })), runInReadCommittedTransaction(t => { return Promise.all([ this.setPreview(videoUpdated, t), @@ -130,7 +133,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder { this.video.description = videoData.description this.video.support = videoData.support this.video.nsfw = videoData.nsfw - this.video.commentsEnabled = videoData.commentsEnabled + this.video.commentsPolicy = videoData.commentsPolicy this.video.downloadEnabled = videoData.downloadEnabled this.video.waitTranscoding = videoData.waitTranscoding this.video.state = videoData.state diff --git a/server/core/lib/automatic-tags/automatic-tagger.ts b/server/core/lib/automatic-tags/automatic-tagger.ts new file mode 100644 index 000000000..634ce62ae --- /dev/null +++ b/server/core/lib/automatic-tags/automatic-tagger.ts @@ -0,0 +1,142 @@ +import { AutomaticTagAvailable, AutomaticTagPolicy, CommentAutomaticTagPolicies } from '@peertube/peertube-models' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { WEBSERVER } from '@server/initializers/constants.js' +import { getServerActor } from '@server/models/application/application.js' +import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js' +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' +import { MAccount, MAccountId, MVideo } from '@server/types/models/index.js' +import Linkifyit from 'linkify-it' +import { Transaction } from 'sequelize' + +const lTags = loggerTagsFactory('automatic-tags') + +const linkifyit = Linkifyit() + +export class AutomaticTagger { + + private static readonly SPECIAL_TAGS = { + EXTERNAL_LINK: 'external-link' + } + + async buildCommentsAutomaticTags (options: { + ownerAccount: MAccount + text: string + transaction?: Transaction + }) { + const { text, ownerAccount, transaction } = options + + const serverAccount = (await getServerActor()).Account + + try { + const [ accountTags, serverTags ] = await Promise.all([ + this.buildAutomaticTags({ account: ownerAccount, text, transaction }), + this.buildAutomaticTags({ account: serverAccount, text, transaction }) + ]) + + logger.debug('Built automatic tags for comment', { text, accountTags, serverTags, ...lTags() }) + + return [ ...accountTags, ...serverTags ] + } catch (err) { + logger.error('Cannot build comment automatic tags', { text, err, ...lTags() }) + + return [] + } + } + + async buildVideoAutomaticTags (options: { + video: MVideo + transaction?: Transaction + }) { + const { video, transaction } = options + + const serverAccount = (await getServerActor()).Account + + try { + const [ videoNameTags, videoDescriptionTags ] = await Promise.all([ + this.buildAutomaticTags({ account: serverAccount, text: video.name, transaction }), + this.buildAutomaticTags({ account: serverAccount, text: video.description, transaction }) + ]) + + logger.debug('Built automatic tags for video', { video, videoNameTags, videoDescriptionTags, ...lTags() }) + + return [ ...videoNameTags, ...videoDescriptionTags ] + } catch (err) { + logger.error('Cannot build video automatic tags', { video, err, ...lTags() }) + + return [] + } + } + + private async buildAutomaticTags (options: { + account: MAccount + text: string + transaction?: Transaction + }) { + const { text, account, transaction } = options + + const tagsDone = new Set<string>() + const automaticTags: { name: string, accountId: number }[] = [] + + // Watched words by account that published the video + const watchedWords = await WatchedWordsListModel.buildWatchedWordsRegexp({ accountId: account.id, transaction }) + + logger.debug(`Got watched words regex for account ${account.getDisplayName()}`, { watchedWords, ...lTags() }) + + for (const { listName, regex } of watchedWords) { + try { + if (regex.test(text)) { + tagsDone.add(listName) + automaticTags.push({ name: listName, accountId: account.id }) + } + } catch (err) { + logger.error('Cannot test regex against text', { regex, err, ...lTags() }) + } + } + + // Core PeerTube tags + if (!tagsDone.has(AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK) && this.hasExternalLinks(text)) { + // This is a global tag, not assigned to a specific account + automaticTags.push({ name: AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK, accountId: account.id }) + tagsDone.add(AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK) + } + + logger.debug('Built automatic tags for text', { text, automaticTags, ...lTags() }) + + return automaticTags + } + + private hasExternalLinks (text: string) { + if (!text) return false + + const matches = linkifyit.match(text) + if (!matches) return false + + logger.debug('Found external links in text', { matches, text, ...lTags() }) + + return matches.some(({ url }) => new URL(url).host !== WEBSERVER.HOST) + } + + // --------------------------------------------------------------------------- + + static async getAutomaticTagPolicies (account: MAccountId) { + const policies = await AccountAutomaticTagPolicyModel.listOfAccount(account) + + const result: CommentAutomaticTagPolicies = { + review: policies.filter(p => p.policy === AutomaticTagPolicy.REVIEW_COMMENT).map(p => p.name) + } + + return result + } + + static async getAutomaticTagAvailable (account: MAccountId) { + const result: AutomaticTagAvailable = { + available: [ + ...(await WatchedWordsListModel.listNamesOf(account)).map(t => ({ name: t, type: 'watched-words-list' as 'watched-words-list' })), + + ...Object.values(AutomaticTagger.SPECIAL_TAGS).map(t => ({ name: t, type: 'core' as 'core' })) + ] + } + + return result + } +} diff --git a/server/core/lib/automatic-tags/automatic-tags.ts b/server/core/lib/automatic-tags/automatic-tags.ts new file mode 100644 index 000000000..3ecf79863 --- /dev/null +++ b/server/core/lib/automatic-tags/automatic-tags.ts @@ -0,0 +1,99 @@ +import { AutomaticTagPolicyType } from '@peertube/peertube-models' +import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js' +import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js' +import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js' +import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js' +import { + MAccountId, + MComment, + MCommentAdminOrUserFormattable, + MCommentAutomaticTagWithTag, + MVideo, + MVideoAutomaticTagWithTag +} from '@server/types/models/index.js' +import { Transaction } from 'sequelize' + +export async function setAndSaveCommentAutomaticTags (options: { + comment: MComment + automaticTags: { accountId: number, name: string }[] + transaction?: Transaction +}) { + const { comment, automaticTags, transaction } = options + + if (automaticTags.length === 0) return + + const commentAutomaticTags: MCommentAutomaticTagWithTag[] = [] + + const accountIds = new Set(automaticTags.map(t => t.accountId)) + for (const accountId of accountIds) { + await CommentAutomaticTagModel.deleteAllOfAccountAndComment({ accountId, commentId: comment.id, transaction }) + } + + for (const tag of automaticTags) { + const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag: tag.name, transaction }) + + const [ commentAutomaticTag ] = await CommentAutomaticTagModel.upsert({ + accountId: tag.accountId, + automaticTagId: automaticTagInstance.id, + commentId: comment.id + }, { transaction }) + + commentAutomaticTag.AutomaticTag = automaticTagInstance + + commentAutomaticTags.push(commentAutomaticTag) + } + + (comment as MCommentAdminOrUserFormattable).CommentAutomaticTags = commentAutomaticTags +} + +export async function setAndSaveVideoAutomaticTags (options: { + video: MVideo + automaticTags: { accountId: number, name: string }[] + transaction?: Transaction +}) { + const { video, automaticTags, transaction } = options + + if (automaticTags.length === 0) return + + const accountIds = new Set(automaticTags.map(t => t.accountId)) + for (const accountId of accountIds) { + await VideoAutomaticTagModel.deleteAllOfAccountAndVideo({ accountId, videoId: video.id, transaction }) + } + + const videoAutomaticTags: MVideoAutomaticTagWithTag[] = [] + + for (const tag of automaticTags) { + const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag: tag.name, transaction }) + + const [ videoAutomaticTag ] = await VideoAutomaticTagModel.upsert({ + accountId: tag.accountId, + automaticTagId: automaticTagInstance.id, + videoId: video.id + }, { transaction }) + + videoAutomaticTag.AutomaticTag = automaticTagInstance + + videoAutomaticTags.push(videoAutomaticTag) + } +} + +export async function setAccountAutomaticTagsPolicy (options: { + account: MAccountId + tags: string[] + policy: AutomaticTagPolicyType + transaction?: Transaction +}) { + const { account, policy, tags, transaction } = options + + await AccountAutomaticTagPolicyModel.deleteOfAccount({ account, policy, transaction }) + + for (const tag of tags) { + const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag, transaction }) + + await AccountAutomaticTagPolicyModel.create({ + policy, + accountId: account.id, + automaticTagId: automaticTagInstance.id + }, { transaction }) + } +} diff --git a/server/core/lib/job-queue/handlers/activitypub-cleaner.ts b/server/core/lib/job-queue/handlers/activitypub-cleaner.ts index 7c8c68fdf..643a641f3 100644 --- a/server/core/lib/job-queue/handlers/activitypub-cleaner.ts +++ b/server/core/lib/job-queue/handlers/activitypub-cleaner.ts @@ -183,7 +183,7 @@ function commentOptionsFactory () { bodyValidator: (body: any) => sanitizeAndCheckVideoCommentObject(body), updater: async (url: string, newUrl: string) => { - const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(url) + const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(url) comment.url = newUrl await comment.save() @@ -192,7 +192,7 @@ function commentOptionsFactory () { }, deleter: async (url) => { - const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(url) + const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(url) await comment.destroy() diff --git a/server/core/lib/job-queue/handlers/video-import.ts b/server/core/lib/job-queue/handlers/video-import.ts index db8f84077..c791a402b 100644 --- a/server/core/lib/job-queue/handlers/video-import.ts +++ b/server/core/lib/job-queue/handlers/video-import.ts @@ -1,6 +1,8 @@ -import { Job } from 'bullmq' -import { move, remove } from 'fs-extra/esm' -import { stat } from 'fs/promises' +import { buildAspectRatio } from '@peertube/peertube-core-utils' +import { + ffprobePromise, + getChaptersFromContainer, getVideoStreamDuration +} from '@peertube/peertube-ffmpeg' import { ThumbnailType, ThumbnailType_Type, @@ -15,20 +17,24 @@ import { import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js' import { CONFIG } from '@server/initializers/config.js' +import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js' +import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js' import { isPostImportVideoAccepted } from '@server/lib/moderation.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { ServerConfigManager } from '@server/lib/server-config-manager.js' import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job.js' import { isUserQuotaValid } from '@server/lib/user.js' +import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' +import { buildNewFile } from '@server/lib/video-file.js' +import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { buildNextVideoState } from '@server/lib/video-state.js' -import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js' -import { - ffprobePromise, - getChaptersFromContainer, getVideoStreamDuration -} from '@peertube/peertube-ffmpeg' +import { Job } from 'bullmq' +import { FfprobeData } from 'fluent-ffmpeg' +import { move, remove } from 'fs-extra/esm' +import { stat } from 'fs/promises' import { logger } from '../../../helpers/logger.js' import { getSecureTorrentName } from '../../../helpers/utils.js' import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent.js' @@ -41,10 +47,6 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js' import { Notifier } from '../../notifier/index.js' import { generateLocalVideoMiniature } from '../../thumbnail.js' import { JobQueue } from '../job-queue.js' -import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' -import { FfprobeData } from 'fluent-ffmpeg' -import { buildNewFile } from '@server/lib/video-file.js' -import { buildAspectRatio } from '@peertube/peertube-core-utils' async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { const payload = job.data as VideoImportPayload @@ -209,6 +211,9 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t }) + const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction: t }) + await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction: t }) + // Now we can federate the video (reload from database, we need more attributes) const videoForFederation = await VideoModel.loadFull(video.uuid, t) await federateVideoIfNeeded(videoForFederation, true, t) diff --git a/server/core/lib/job-queue/handlers/video-live-ending.ts b/server/core/lib/job-queue/handlers/video-live-ending.ts index 28b4aeb5c..496367eda 100644 --- a/server/core/lib/job-queue/handlers/video-live-ending.ts +++ b/server/core/lib/job-queue/handlers/video-live-ending.ts @@ -120,7 +120,7 @@ async function saveReplayToExternalVideo (options: { category: liveVideo.category, licence: liveVideo.licence, language: liveVideo.language, - commentsEnabled: liveVideo.commentsEnabled, + commentsPolicy: liveVideo.commentsPolicy, downloadEnabled: liveVideo.downloadEnabled, waitTranscoding: true, nsfw: liveVideo.nsfw, diff --git a/server/core/lib/local-video-creator.ts b/server/core/lib/local-video-creator.ts index d025a3e3f..4414a52a8 100644 --- a/server/core/lib/local-video-creator.ts +++ b/server/core/lib/local-video-creator.ts @@ -24,6 +24,8 @@ import { FfprobeData } from 'fluent-ffmpeg' import { move } from 'fs-extra/esm' import { getLocalVideoActivityPubUrl } from './activitypub/url.js' import { federateVideoIfNeeded } from './activitypub/videos/federate.js' +import { AutomaticTagger } from './automatic-tags/automatic-tagger.js' +import { setAndSaveVideoAutomaticTags } from './automatic-tags/automatic-tags.js' import { Hooks } from './plugins/hooks.js' import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js' import { autoBlacklistVideoIfNeeded } from './video-blacklist.js' @@ -31,7 +33,7 @@ import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video import { buildNewFile, createVideoSource } from './video-file.js' import { addVideoJobsAfterCreation } from './video-jobs.js' import { VideoPathManager } from './video-path-manager.js' -import { setVideoTags } from './video.js' +import { buildCommentsPolicy, setVideoTags } from './video.js' type VideoAttributes = Omit<VideoCreate, 'channelId'> & { duration: number @@ -143,6 +145,9 @@ export class LocalVideoCreator { await setVideoTags({ video: this.video, tags: this.videoAttributes.tags, transaction }) + const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video: this.video, transaction }) + await setAndSaveVideoAutomaticTags({ video: this.video, automaticTags, transaction }) + // Schedule an update in the future? if (this.videoAttributes.scheduleUpdate) { await ScheduleVideoUpdateModel.create({ @@ -271,7 +276,7 @@ export class LocalVideoCreator { category: videoInfo.category, licence: videoInfo.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE, language: videoInfo.language, - commentsEnabled: videoInfo.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED, + commentsPolicy: buildCommentsPolicy(videoInfo), downloadEnabled: videoInfo.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED, waitTranscoding: videoInfo.waitTranscoding || false, nsfw: videoInfo.nsfw || false, diff --git a/server/core/lib/notifier/notifier.ts b/server/core/lib/notifier/notifier.ts index db835e89b..277f0afb7 100644 --- a/server/core/lib/notifier/notifier.ts +++ b/server/core/lib/notifier/notifier.ts @@ -54,6 +54,7 @@ class Notifier { publicationAfterScheduleUpdate: [ OwnedPublicationAfterScheduleUpdate ], publicationAfterAutoUnblacklist: [ OwnedPublicationAfterAutoUnblacklist ], newComment: [ CommentMention, NewCommentForVideoOwner ], + commentApproval: [ CommentMention ], newAbuse: [ NewAbuseForModerators ], newBlacklist: [ NewBlacklistForOwner ], unblacklist: [ UnblacklistForOwner ], @@ -123,6 +124,15 @@ class Notifier { .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err })) } + notifyOnNewCommentApproval (comment: MCommentOwnerVideo): void { + const models = this.notificationModels.commentApproval + + logger.debug('Notify on comment approval', { comment: comment.url, ...lTags() }) + + this.sendNotifications(models, comment) + .catch(err => logger.error('Cannot notify on comment approval %s.', comment.url, { err })) + } + notifyOnNewAbuse (payload: NewAbusePayload): void { const models = this.notificationModels.newAbuse diff --git a/server/core/lib/notifier/shared/comment/comment-mention.ts b/server/core/lib/notifier/shared/comment/comment-mention.ts index e6bba1eee..990f6c114 100644 --- a/server/core/lib/notifier/shared/comment/comment-mention.ts +++ b/server/core/lib/notifier/shared/comment/comment-mention.ts @@ -24,6 +24,10 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU private accountMutedHash: { [ id: number ]: boolean } private instanceMutedHash: { [ id: number ]: boolean } + isDisabled () { + return this.payload.heldForReview === true + } + async prepare () { const extractedUsernames = this.payload.extractMentions() logger.debug( @@ -52,7 +56,7 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU } log () { - logger.info('Notifying %d users of new comment %s.', this.users.length, this.payload.url) + logger.info('Notifying %d users of new comment mention %s.', this.users.length, this.payload.url) } getSetting (user: MUserNotifSettingAccount) { diff --git a/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts b/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts index eddb162f3..8e07c8491 100644 --- a/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts +++ b/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts @@ -50,10 +50,15 @@ export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwner } createEmail (to: string) { - const video = this.payload.Video - const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath() - const commentUrl = WEBSERVER.URL + this.payload.getCommentStaticPath() - const commentHtml = toSafeHtml(this.payload.text) + const comment = this.payload + + const video = comment.Video + const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + const commentHtml = toSafeHtml(comment.text) + + const commentUrl = comment.heldForReview + ? WEBSERVER.URL + comment.getCommentUserReviewPath() + : WEBSERVER.URL + comment.getCommentStaticPath() return { template: 'video-comment-new', @@ -66,8 +71,11 @@ export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwner commentHtml, video, videoUrl, + requiresApproval: this.payload.heldForReview, action: { - text: 'View comment', + text: comment.heldForReview + ? 'Review comment' + : 'View comment', url: commentUrl } } diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts index 5569b571e..523c2e554 100644 --- a/server/core/lib/server-config-manager.ts +++ b/server/core/lib/server-config-manager.ts @@ -3,6 +3,7 @@ import { RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig, + VideoCommentPolicy, VideoResolutionType } from '@peertube/peertube-models' import { getServerCommit } from '@server/helpers/version.js' @@ -72,7 +73,11 @@ class ServerConfigManager { defaults: { publish: { downloadEnabled: CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED, - commentsEnabled: CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED, + + commentsPolicy: CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY, + // TODO: remove, deprecated in 6.2 + commentsEnabled: CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY !== VideoCommentPolicy.DISABLED, + privacy: CONFIG.DEFAULTS.PUBLISH.PRIVACY, licence: CONFIG.DEFAULTS.PUBLISH.LICENCE }, diff --git a/server/core/lib/stat-manager.ts b/server/core/lib/stat-manager.ts index 3a44a5f70..202502ef4 100644 --- a/server/core/lib/stat-manager.ts +++ b/server/core/lib/stat-manager.ts @@ -147,7 +147,9 @@ class StatsManager { Like: 0, Dislike: 0, Flag: 0, - View: 0 + View: 0, + ApproveReply: 0, + RejectReply: 0 } } @@ -170,6 +172,8 @@ class StatsManager { totalActivityPubDislikeMessagesSuccesses: this.inboxMessages.successesPerType.Dislike, totalActivityPubFlagMessagesSuccesses: this.inboxMessages.successesPerType.Flag, totalActivityPubViewMessagesSuccesses: this.inboxMessages.successesPerType.View, + totalActivityPubApproveReplyMessagesSuccesses: this.inboxMessages.successesPerType.ApproveReply, + totalActivityPubRejectReplyMessagesSuccesses: this.inboxMessages.successesPerType.RejectReply, totalActivityPubCreateMessagesErrors: this.inboxMessages.errorsPerType.Create, totalActivityPubUpdateMessagesErrors: this.inboxMessages.errorsPerType.Update, @@ -183,6 +187,8 @@ class StatsManager { totalActivityPubDislikeMessagesErrors: this.inboxMessages.errorsPerType.Dislike, totalActivityPubFlagMessagesErrors: this.inboxMessages.errorsPerType.Flag, totalActivityPubViewMessagesErrors: this.inboxMessages.errorsPerType.View, + totalActivityPubApproveReplyMessagesErrors: this.inboxMessages.errorsPerType.ApproveReply, + totalActivityPubRejectReplyMessagesErrors: this.inboxMessages.errorsPerType.RejectReply, totalActivityPubMessagesErrors: this.inboxMessages.errors, diff --git a/server/core/lib/user-import-export/exporters/auto-tag-policies.ts b/server/core/lib/user-import-export/exporters/auto-tag-policies.ts new file mode 100644 index 000000000..ded6eef8b --- /dev/null +++ b/server/core/lib/user-import-export/exporters/auto-tag-policies.ts @@ -0,0 +1,18 @@ +import { AutoTagPoliciesJSON } from '@peertube/peertube-models' +import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js' +import { AbstractUserExporter } from './abstract-user-exporter.js' + +export class AutoTagPoliciesExporter extends AbstractUserExporter <AutoTagPoliciesJSON> { + + async export () { + const data = await AutomaticTagger.getAutomaticTagPolicies(this.user.Account) + + return { + json: { + reviewComments: data.review.map(name => ({ name })) + } as AutoTagPoliciesJSON, + + staticFiles: [] + } + } +} diff --git a/server/core/lib/user-import-export/exporters/comments-exporter.ts b/server/core/lib/user-import-export/exporters/comments-exporter.ts index 47f850118..a2a57e07c 100644 --- a/server/core/lib/user-import-export/exporters/comments-exporter.ts +++ b/server/core/lib/user-import-export/exporters/comments-exporter.ts @@ -34,9 +34,9 @@ export class CommentsExporter extends AbstractUserExporter <CommentsExportJSON> private formatCommentsAP (comments: MCommentExport[]) { return Bluebird.mapSeries(comments, async ({ url }) => { - const comment = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url) + const comment = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url) - const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, undefined) + const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment }) let commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject const isPublic = true // Comments are always public diff --git a/server/core/lib/user-import-export/exporters/index.ts b/server/core/lib/user-import-export/exporters/index.ts index 21ec0b37c..5e0abe5f2 100644 --- a/server/core/lib/user-import-export/exporters/index.ts +++ b/server/core/lib/user-import-export/exporters/index.ts @@ -1,4 +1,6 @@ +export * from './abstract-user-exporter.js' export * from './account-exporter.js' +export * from './auto-tag-policies.js' export * from './blocklist-exporter.js' export * from './channels-exporter.js' export * from './comments-exporter.js' @@ -6,8 +8,8 @@ export * from './dislikes-exporter.js' export * from './followers-exporter.js' export * from './following-exporter.js' export * from './likes-exporter.js' -export * from './abstract-user-exporter.js' export * from './user-settings-exporter.js' export * from './user-video-history-exporter.js' export * from './video-playlists-exporter.js' export * from './videos-exporter.js' +export * from './watched-words-lists-exporter.js' diff --git a/server/core/lib/user-import-export/exporters/videos-exporter.ts b/server/core/lib/user-import-export/exporters/videos-exporter.ts index 887a5b0ef..d34c1fc62 100644 --- a/server/core/lib/user-import-export/exporters/videos-exporter.ts +++ b/server/core/lib/user-import-export/exporters/videos-exporter.ts @@ -1,5 +1,5 @@ import { pick } from '@peertube/peertube-core-utils' -import { ActivityCreate, FileStorage, VideoExportJSON, VideoObject, VideoPrivacy } from '@peertube/peertube-models' +import { ActivityCreate, FileStorage, VideoCommentPolicy, VideoExportJSON, VideoObject, VideoPrivacy } from '@peertube/peertube-models' import { logger } from '@server/helpers/logger.js' import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js' import { audiencify, getAudience } from '@server/lib/activitypub/audience.js' @@ -151,7 +151,10 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> { nsfw: video.nsfw, - commentsEnabled: video.commentsEnabled, + commentsPolicy: video.commentsPolicy, + // TODO: remove, deprecated in 6.2 + commentsEnabled: video.commentsPolicy !== VideoCommentPolicy.DISABLED, + downloadEnabled: video.downloadEnabled, waitTranscoding: video.waitTranscoding, diff --git a/server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts b/server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts new file mode 100644 index 000000000..4bb5c0ec8 --- /dev/null +++ b/server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts @@ -0,0 +1,23 @@ +import { WatchedWordsListsJSON } from '@peertube/peertube-models' +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' +import { AbstractUserExporter } from './abstract-user-exporter.js' + +export class WatchedWordsListsExporter extends AbstractUserExporter <WatchedWordsListsJSON> { + + async export () { + const data = await WatchedWordsListModel.listForExport({ accountId: this.user.Account.id }) + + return { + json: { + watchedWordLists: data.map(list => ({ + listName: list.listName, + words: list.words, + createdAt: list.createdAt.toISOString(), + updatedAt: list.updatedAt.toISOString() + })) + } as WatchedWordsListsJSON, + + staticFiles: [] + } + } +} diff --git a/server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts b/server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts new file mode 100644 index 000000000..c19030b0f --- /dev/null +++ b/server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts @@ -0,0 +1,31 @@ +import { AutoTagPoliciesJSON, AutomaticTagPolicy } from '@peertube/peertube-models' +import { isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js' +import { setAccountAutomaticTagsPolicy } from '@server/lib/automatic-tags/automatic-tags.js' +import { AbstractUserImporter } from './abstract-user-importer.js' + +type SanitizedObject = AutoTagPoliciesJSON['reviewComments'] + +// eslint-disable-next-line max-len +export class ReviewCommentsTagPoliciesImporter + extends AbstractUserImporter <AutoTagPoliciesJSON, AutoTagPoliciesJSON['reviewComments'] & { archiveFiles?: never }, SanitizedObject> { + + protected getImportObjects (json: AutoTagPoliciesJSON) { + if (!json.reviewComments) return [] + + return [ json.reviewComments ] + } + + protected sanitize (data: AutoTagPoliciesJSON['reviewComments']) { + return data.filter(d => isWatchedWordListNameValid(d.name)) + } + + protected async importObject (data: SanitizedObject) { + await setAccountAutomaticTagsPolicy({ + account: this.user.Account, + policy: AutomaticTagPolicy.REVIEW_COMMENT, + tags: data.map(v => v.name) + }) + + return { duplicate: false } + } +} diff --git a/server/core/lib/user-import-export/importers/user-video-history-importer.ts b/server/core/lib/user-import-export/importers/user-video-history-importer.ts index bdd7ae5d1..402330a28 100644 --- a/server/core/lib/user-import-export/importers/user-video-history-importer.ts +++ b/server/core/lib/user-import-export/importers/user-video-history-importer.ts @@ -1,14 +1,14 @@ -import { UserVideoHistoryExportJSON } from '@peertube/peertube-models' -import { AbstractRatesImporter } from './abstract-rates-importer.js' -import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js' import { pick } from '@peertube/peertube-core-utils' +import { UserVideoHistoryExportJSON } from '@peertube/peertube-models' +import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js' import { loadOrCreateVideoIfAllowedForUser } from '@server/lib/model-loaders/video.js' import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js' +import { AbstractUserImporter } from './abstract-user-importer.js' -type SanitizedObject = Pick<UserVideoHistoryExportJSON['watchedVideos'][0], 'videoUrl' | 'lastTimecode'> +type SanitizedObject = Pick<UserVideoHistoryExportJSON['watchedVideos'][0], 'videoUrl' | 'lastTimecode' | 'archiveFiles'> // eslint-disable-next-line max-len -export class UserVideoHistoryImporter extends AbstractRatesImporter <UserVideoHistoryExportJSON, UserVideoHistoryExportJSON['watchedVideos'][0]> { +export class UserVideoHistoryImporter extends AbstractUserImporter <UserVideoHistoryExportJSON, UserVideoHistoryExportJSON['watchedVideos'][0], SanitizedObject> { protected getImportObjects (json: UserVideoHistoryExportJSON) { return json.watchedVideos diff --git a/server/core/lib/user-import-export/importers/videos-importer.ts b/server/core/lib/user-import-export/importers/videos-importer.ts index b648ecc45..8877025b1 100644 --- a/server/core/lib/user-import-export/importers/videos-importer.ts +++ b/server/core/lib/user-import-export/importers/videos-importer.ts @@ -1,6 +1,13 @@ import { pick } from '@peertube/peertube-core-utils' import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg' -import { LiveVideoLatencyMode, ThumbnailType, VideoExportJSON, VideoPrivacy, VideoState } from '@peertube/peertube-models' +import { + LiveVideoLatencyMode, + ThumbnailType, + VideoCommentPolicy, + VideoExportJSON, + VideoPrivacy, + VideoState +} from '@peertube/peertube-models' import { buildUUID, getFileSize } from '@peertube/peertube-node-utils' import { isArray, isBooleanValid, isUUIDValid } from '@server/helpers/custom-validators/misc.js' import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js' @@ -10,6 +17,7 @@ import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video- import { isPasswordValid, isVideoCategoryValid, + isVideoCommentsPolicyValid, isVideoDescriptionValid, isVideoDurationValid, isVideoLanguageValid, @@ -42,7 +50,7 @@ const lTags = loggerTagsFactory('user-import') type ImportObject = VideoExportJSON['videos'][0] type SanitizedObject = Pick<ImportObject, 'name' | 'duration' | 'channel' | 'privacy' | 'archiveFiles' | 'captions' | 'category' | -'licence' | 'language' | 'description' | 'support' | 'nsfw' | 'isLive' | 'commentsEnabled' | 'downloadEnabled' | 'waitTranscoding' | +'licence' | 'language' | 'description' | 'support' | 'nsfw' | 'isLive' | 'commentsPolicy' | 'downloadEnabled' | 'waitTranscoding' | 'originallyPublishedAt' | 'tags' | 'live' | 'passwords' | 'source' | 'chapters'> export class VideosImporter extends AbstractUserImporter <VideoExportJSON, ImportObject, SanitizedObject> { @@ -59,17 +67,27 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor if (o.isLive !== true && !o.archiveFiles?.videoFile) return undefined if (!isVideoCategoryValid(o.category)) o.category = null - if (!isVideoLicenceValid(o.licence)) o.licence = CONFIG.DEFAULTS.PUBLISH.LICENCE + if (!o.licence || !isVideoLicenceValid(o.licence)) o.licence = CONFIG.DEFAULTS.PUBLISH.LICENCE if (!isVideoLanguageValid(o.language)) o.language = null if (!isVideoDescriptionValid(o.description)) o.description = null if (!isVideoSupportValid(o.support)) o.support = null if (!isBooleanValid(o.nsfw)) o.nsfw = false if (!isBooleanValid(o.isLive)) o.isLive = false - if (!isBooleanValid(o.commentsEnabled)) o.commentsEnabled = CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED if (!isBooleanValid(o.downloadEnabled)) o.downloadEnabled = CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED if (!isBooleanValid(o.waitTranscoding)) o.waitTranscoding = true + if (!o.commentsPolicy || !isVideoCommentsPolicyValid(o.commentsPolicy)) { + // Fallback to deprecated property + if (isBooleanValid(o.commentsEnabled)) { + o.commentsPolicy = o.commentsEnabled === true + ? VideoCommentPolicy.ENABLED + : VideoCommentPolicy.DISABLED + } else { + o.commentsPolicy = CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY + } + } + if (!isVideoSourceFilenameValid(o.source?.inputFilename)) o.source = undefined if (!isVideoOriginallyPublishedAtValid(o.originallyPublishedAt)) o.originallyPublishedAt = null @@ -89,7 +107,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor if (!isBooleanValid(o.live.saveReplay)) o.live.saveReplay = false if (o.live.saveReplay && !isVideoReplayPrivacyValid(o.live.replaySettings.privacy)) return undefined - if (!isLiveLatencyModeValid(o.live.latencyMode)) o.live.latencyMode = LiveVideoLatencyMode.DEFAULT + if (!o.live.latencyMode || !isLiveLatencyModeValid(o.live.latencyMode)) o.live.latencyMode = LiveVideoLatencyMode.DEFAULT if (!o.live.streamKey) o.live.streamKey = buildUUID() else if (!isUUIDValid(o.live.streamKey)) return undefined @@ -114,7 +132,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor 'support', 'nsfw', 'isLive', - 'commentsEnabled', + 'commentsPolicy', 'downloadEnabled', 'waitTranscoding', 'originallyPublishedAt', @@ -204,7 +222,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor 'isLive', 'nsfw', 'tags', - 'commentsEnabled', + 'commentsPolicy', 'downloadEnabled', 'waitTranscoding', 'originallyPublishedAt' diff --git a/server/core/lib/user-import-export/importers/watched-words-lists-importer.ts b/server/core/lib/user-import-export/importers/watched-words-lists-importer.ts new file mode 100644 index 000000000..3bd457035 --- /dev/null +++ b/server/core/lib/user-import-export/importers/watched-words-lists-importer.ts @@ -0,0 +1,35 @@ +import { pick } from '@peertube/peertube-core-utils' +import { WatchedWordsListsJSON } from '@peertube/peertube-models' +import { areWatchedWordsValid, isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js' +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' +import { AbstractUserImporter } from './abstract-user-importer.js' + +type SanitizedObject = Pick<WatchedWordsListsJSON['watchedWordLists'][0], 'listName' | 'words'> + +// eslint-disable-next-line max-len +export class WatchedWordsListsImporter extends AbstractUserImporter <WatchedWordsListsJSON, WatchedWordsListsJSON['watchedWordLists'][0], SanitizedObject> { + + protected getImportObjects (json: WatchedWordsListsJSON) { + return json.watchedWordLists + } + + protected sanitize (data: WatchedWordsListsJSON['watchedWordLists'][0]) { + if (!isWatchedWordListNameValid(data.listName)) return undefined + if (!areWatchedWordsValid(data.words)) return undefined + + return pick(data, [ 'listName', 'words' ]) + } + + protected async importObject (data: SanitizedObject) { + const accountId = this.user.Account.id + const existing = await WatchedWordsListModel.loadByListName({ listName: data.listName, accountId }) + + if (existing) { + await existing.updateList({ listName: data.listName, words: data.words }) + } else { + await WatchedWordsListModel.createList({ accountId, listName: data.listName, words: data.words }) + } + + return { duplicate: false } + } +} diff --git a/server/core/lib/user-import-export/user-exporter.ts b/server/core/lib/user-import-export/user-exporter.ts index 4da1d03e5..e7cd1c9de 100644 --- a/server/core/lib/user-import-export/user-exporter.ts +++ b/server/core/lib/user-import-export/user-exporter.ts @@ -1,6 +1,23 @@ +import { FileStorage, UserExportState } from '@peertube/peertube-models' +import { getFileSize } from '@peertube/peertube-node-utils' +import { activityPubContextify } from '@server/helpers/activity-pub-utils.js' +import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { UserModel } from '@server/models/user/user.js' +import { MUserDefault, MUserExport } from '@server/types/models/index.js' +import archiver, { Archiver } from 'archiver' +import { createWriteStream } from 'fs' +import { remove } from 'fs-extra/esm' import { join, parse } from 'path' +import { PassThrough, Readable, Writable } from 'stream' +import { activityPubCollection } from '../activitypub/collection.js' +import { getContextFilter } from '../activitypub/context.js' +import { getUserExportFileObjectStorageSize, removeUserExportObjectStorage, storeUserExportFile } from '../object-storage/user-export.js' +import { getFSUserExportFilePath } from '../paths.js' import { + AbstractUserExporter, AccountExporter, + AutoTagPoliciesExporter, BlocklistExporter, ChannelsExporter, CommentsExporter, @@ -8,27 +25,13 @@ import { ExportResult, FollowersExporter, FollowingExporter, - LikesExporter, AbstractUserExporter, + LikesExporter, UserSettingsExporter, + UserVideoHistoryExporter, VideoPlaylistsExporter, VideosExporter, - UserVideoHistoryExporter + WatchedWordsListsExporter } from './exporters/index.js' -import { MUserDefault, MUserExport } from '@server/types/models/index.js' -import archiver, { Archiver } from 'archiver' -import { createWriteStream } from 'fs' -import { logger, loggerTagsFactory } from '@server/helpers/logger.js' -import { PassThrough, Readable, Writable } from 'stream' -import { activityPubContextify } from '@server/helpers/activity-pub-utils.js' -import { getContextFilter } from '../activitypub/context.js' -import { activityPubCollection } from '../activitypub/collection.js' -import { FileStorage, UserExportState } from '@peertube/peertube-models' -import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js' -import { UserModel } from '@server/models/user/user.js' -import { getFSUserExportFilePath } from '../paths.js' -import { getUserExportFileObjectStorageSize, removeUserExportObjectStorage, storeUserExportFile } from '../object-storage/user-export.js' -import { getFileSize } from '@peertube/peertube-node-utils' -import { remove } from 'fs-extra/esm' const lTags = loggerTagsFactory('user-export') @@ -245,6 +248,14 @@ export class UserExporter { { jsonFilename: 'video-history.json', exporter: new UserVideoHistoryExporter(options) + }, + { + jsonFilename: 'watched-words-lists.json', + exporter: new WatchedWordsListsExporter(options) + }, + { + jsonFilename: 'automatic-tag-policies.json', + exporter: new AutoTagPoliciesExporter(options) } ] as { jsonFilename: string, exporter: AbstractUserExporter<any> }[] } diff --git a/server/core/lib/user-import-export/user-importer.ts b/server/core/lib/user-import-export/user-importer.ts index 4654f05d8..acbd6f2b5 100644 --- a/server/core/lib/user-import-export/user-importer.ts +++ b/server/core/lib/user-import-export/user-importer.ts @@ -1,23 +1,25 @@ -import { MUserDefault, MUserImport } from '@server/types/models/index.js' -import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { UserImportResultSummary, UserImportState } from '@peertube/peertube-models' -import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js' -import { getFSUserImportFilePath } from '../paths.js' -import { remove } from 'fs-extra/esm' -import { unzip } from '@server/helpers/unzip.js' import { getFilenameWithoutExt } from '@peertube/peertube-node-utils' -import { VideosImporter } from './importers/videos-importer.js' +import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { unzip } from '@server/helpers/unzip.js' import { UserModel } from '@server/models/user/user.js' +import { MUserDefault, MUserImport } from '@server/types/models/index.js' +import { remove } from 'fs-extra/esm' import { dirname, join } from 'path' -import { AccountImporter } from './importers/account-importer.js' -import { UserSettingsImporter } from './importers/user-settings-importer.js' -import { ChannelsImporter } from './importers/channels-importer.js' +import { getFSUserImportFilePath } from '../paths.js' import { BlocklistImporter } from './importers/account-blocklist-importer.js' +import { AccountImporter } from './importers/account-importer.js' +import { ChannelsImporter } from './importers/channels-importer.js' +import { DislikesImporter } from './importers/dislikes-importer.js' import { FollowingImporter } from './importers/following-importer.js' import { LikesImporter } from './importers/likes-importer.js' -import { DislikesImporter } from './importers/dislikes-importer.js' -import { VideoPlaylistsImporter } from './importers/video-playlists-importer.js' +import { ReviewCommentsTagPoliciesImporter } from './importers/review-comments-tag-policies-importer.js' +import { UserSettingsImporter } from './importers/user-settings-importer.js' import { UserVideoHistoryImporter } from './importers/user-video-history-importer.js' +import { VideoPlaylistsImporter } from './importers/video-playlists-importer.js' +import { VideosImporter } from './importers/videos-importer.js' +import { WatchedWordsListsImporter } from './importers/watched-words-lists-importer.js' const lTags = loggerTagsFactory('user-import') @@ -36,7 +38,9 @@ export class UserImporter { videos: this.buildSummary(), account: this.buildSummary(), userSettings: this.buildSummary(), - userVideoHistory: this.buildSummary() + userVideoHistory: this.buildSummary(), + watchedWordsLists: this.buildSummary(), + commentAutoTagPolicies: this.buildSummary() } } @@ -133,6 +137,14 @@ export class UserImporter { { name: 'userVideoHistory' as 'userVideoHistory', importer: new UserVideoHistoryImporter(this.buildImporterOptions(user, 'video-history.json')) + }, + { + name: 'watchedWordsLists' as 'watchedWordsLists', + importer: new WatchedWordsListsImporter(this.buildImporterOptions(user, 'watched-words-lists.json')) + }, + { + name: 'commentAutoTagPolicies' as 'commentAutoTagPolicies', + importer: new ReviewCommentsTagPoliciesImporter(this.buildImporterOptions(user, 'automatic-tag-policies.json')) } ] } diff --git a/server/core/lib/video-comment.ts b/server/core/lib/video-comment.ts index f89215671..2c65b387e 100644 --- a/server/core/lib/video-comment.ts +++ b/server/core/lib/video-comment.ts @@ -1,27 +1,31 @@ -import { ResultList, VideoCommentThreadTree } from '@peertube/peertube-models' +import { AutomaticTagPolicy, ResultList, UserRight, VideoCommentPolicy, VideoCommentThreadTree } from '@peertube/peertube-models' import { logger } from '@server/helpers/logger.js' import { sequelizeTypescript } from '@server/initializers/database.js' +import { AccountModel } from '@server/models/account/account.js' +import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js' import express from 'express' import cloneDeep from 'lodash-es/cloneDeep.js' -import * as Sequelize from 'sequelize' +import { Transaction } from 'sequelize' import { VideoCommentModel } from '../models/video/video-comment.js' import { - MAccountDefault, MComment, MCommentFormattable, MCommentOwnerVideo, - MCommentOwnerVideoReply, + MCommentOwnerVideoReply, MUserAccountId, MVideoAccountLight, MVideoFullLight } from '../types/models/index.js' -import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send/index.js' +import { sendCreateVideoCommentIfNeeded, sendDeleteVideoComment, sendReplyApproval } from './activitypub/send/index.js' import { getLocalVideoCommentActivityPubUrl } from './activitypub/url.js' +import { AutomaticTagger } from './automatic-tags/automatic-tagger.js' +import { setAndSaveCommentAutomaticTags } from './automatic-tags/automatic-tags.js' +import { Notifier } from './notifier/notifier.js' import { Hooks } from './plugins/hooks.js' -async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) { +export async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) { let videoCommentInstanceBefore: MCommentOwnerVideo await sequelizeTypescript.transaction(async t => { - const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t) + const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(commentArg.url, t) videoCommentInstanceBefore = cloneDeep(comment) @@ -39,42 +43,83 @@ async function removeComment (commentArg: MComment, req: express.Request, res: e Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res }) } -async function createVideoComment (obj: { +export async function approveComment (commentArg: MComment) { + await sequelizeTypescript.transaction(async t => { + const comment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(commentArg.id, t) + + const oldHeldForReview = comment.heldForReview + + comment.heldForReview = false + await comment.save({ transaction: t }) + + if (comment.isOwned()) { + await sendCreateVideoCommentIfNeeded(comment, t) + } else { + sendReplyApproval(comment, 'ApproveReply') + } + + if (oldHeldForReview !== comment.heldForReview) { + Notifier.Instance.notifyOnNewCommentApproval(comment) + } + + logger.info('Video comment %d approved.', comment.id) + }) +} + +export async function createLocalVideoComment (options: { text: string inReplyToComment: MComment | null video: MVideoFullLight - account: MAccountDefault -}, t: Sequelize.Transaction) { + user: MUserAccountId +}) { + const { user, video, text, inReplyToComment } = options + let originCommentId: number | null = null let inReplyToCommentId: number | null = null - if (obj.inReplyToComment && obj.inReplyToComment !== null) { - originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id - inReplyToCommentId = obj.inReplyToComment.id + if (inReplyToComment && inReplyToComment !== null) { + originCommentId = inReplyToComment.originCommentId || inReplyToComment.id + inReplyToCommentId = inReplyToComment.id } - const comment = await VideoCommentModel.create({ - text: obj.text, - originCommentId, - inReplyToCommentId, - videoId: obj.video.id, - accountId: obj.account.id, - url: new Date().toISOString() - }, { transaction: t, validate: false }) + return sequelizeTypescript.transaction(async transaction => { + const account = await AccountModel.load(user.Account.id, transaction) - comment.url = getLocalVideoCommentActivityPubUrl(obj.video, comment) + const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ + ownerAccount: video.VideoChannel.Account, + text, + transaction + }) - const savedComment: MCommentOwnerVideoReply = await comment.save({ transaction: t }) - savedComment.InReplyToVideoComment = obj.inReplyToComment - savedComment.Video = obj.video - savedComment.Account = obj.account + const heldForReview = await shouldCommentBeHeldForReview({ user, video, automaticTags, transaction }) - await sendCreateVideoComment(savedComment, t) + const comment = await VideoCommentModel.create({ + text, + originCommentId, + inReplyToCommentId, + videoId: video.id, + accountId: account.id, + heldForReview, + url: new Date().toISOString() + }, { transaction, validate: false }) - return savedComment + comment.url = getLocalVideoCommentActivityPubUrl(video, comment) + + const savedComment: MCommentOwnerVideoReply = await comment.save({ transaction }) + + await setAndSaveCommentAutomaticTags({ comment: savedComment, automaticTags, transaction }) + + savedComment.InReplyToVideoComment = inReplyToComment + savedComment.Video = video + savedComment.Account = account + + await sendCreateVideoCommentIfNeeded(savedComment, transaction) + + return savedComment + }) } -function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree { +export function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree { // Comments are sorted by id ASC const comments = resultList.data @@ -106,10 +151,32 @@ function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>) return thread } -// --------------------------------------------------------------------------- +export async function shouldCommentBeHeldForReview (options: { + user: MUserAccountId + video: MVideoAccountLight + automaticTags: { name: string, accountId: number }[] + transaction?: Transaction +}) { + const { user, video, transaction, automaticTags } = options -export { - removeComment, - createVideoComment, - buildFormattedCommentTree + if (video.isOwned() && user) { + if (user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) return false + if (user.Account.id === video.VideoChannel.accountId) return false + } + + if (video.commentsPolicy === VideoCommentPolicy.REQUIRES_APPROVAL) return true + if (video.isOwned() !== true) return false + + const ownerAccountTags = automaticTags + .filter(t => t.accountId === video.VideoChannel.accountId) + .map(t => t.name) + + if (ownerAccountTags.length === 0) return false + + return AccountAutomaticTagPolicyModel.hasPolicyOnTags({ + accountId: video.VideoChannel.accountId, + policy: AutomaticTagPolicy.REVIEW_COMMENT, + tags: ownerAccountTags, + transaction + }) } diff --git a/server/core/lib/video-pre-import.ts b/server/core/lib/video-pre-import.ts index 484474f92..ceca6580b 100644 --- a/server/core/lib/video-pre-import.ts +++ b/server/core/lib/video-pre-import.ts @@ -1,4 +1,3 @@ -import { remove } from 'fs-extra/esm' import { ThumbnailType, ThumbnailType_Type, @@ -18,7 +17,7 @@ import { sequelizeTypescript } from '@server/initializers/database.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { ServerConfigManager } from '@server/lib/server-config-manager.js' import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js' -import { setVideoTags } from '@server/lib/video.js' +import { buildCommentsPolicy, setVideoTags } from '@server/lib/video.js' import { VideoImportModel } from '@server/models/video/video-import.js' import { VideoPasswordModel } from '@server/models/video/video-password.js' import { VideoModel } from '@server/models/video/video.js' @@ -34,10 +33,11 @@ import { MVideoThumbnail, MVideoWithBlacklistLight } from '@server/types/models/index.js' +import { remove } from 'fs-extra/esm' import { getLocalVideoActivityPubUrl } from './activitypub/url.js' import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' -import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js' import { createLocalCaption } from './video-captions.js' +import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js' class YoutubeDlImportError extends Error { code: YoutubeDlImportError.CODE @@ -127,7 +127,7 @@ async function buildVideoFromImport ({ channelId, importData, importDataOverride category: importDataOverride?.category || importData.category, licence: importDataOverride?.licence ?? importData.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE, language: importDataOverride?.language || importData.language, - commentsEnabled: importDataOverride?.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED, + commentsPolicy: buildCommentsPolicy(importDataOverride), downloadEnabled: importDataOverride?.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED, waitTranscoding: importDataOverride?.waitTranscoding ?? true, state: VideoState.TO_IMPORT, @@ -272,10 +272,7 @@ async function buildYoutubeDLImport (options: { // --------------------------------------------------------------------------- export { - buildYoutubeDLImport, - YoutubeDlImportError, - insertFromImportIntoDB, - buildVideoFromImport + YoutubeDlImportError, buildVideoFromImport, buildYoutubeDLImport, insertFromImportIntoDB } // --------------------------------------------------------------------------- diff --git a/server/core/lib/video.ts b/server/core/lib/video.ts index 49b47a978..2e0d9e75b 100644 --- a/server/core/lib/video.ts +++ b/server/core/lib/video.ts @@ -1,9 +1,11 @@ -import memoizee from 'memoizee' -import { Transaction } from 'sequelize' +import { VideoCommentPolicy, VideoCommentPolicyType } from '@peertube/peertube-models' +import { CONFIG } from '@server/initializers/config.js' import { MEMOIZE_LENGTH, MEMOIZE_TTL } from '@server/initializers/constants.js' import { TagModel } from '@server/models/video/tag.js' import { VideoModel } from '@server/models/video/video.js' import { MVideoTag } from '@server/types/models/index.js' +import memoizee from 'memoizee' +import { Transaction } from 'sequelize' // --------------------------------------------------------------------------- @@ -15,7 +17,7 @@ export async function setVideoTags (options: { const { video, tags, transaction } = options const internalTags = tags || [] - const tagInstances = await TagModel.findOrCreateTags(internalTags, transaction) + const tagInstances = await TagModel.findOrCreateMultiple({ tags: internalTags, transaction }) await video.$set('Tags', tagInstances, { transaction }) video.Tags = tagInstances @@ -38,3 +40,17 @@ export const getCachedVideoDuration = memoizee(getVideoDuration, { max: MEMOIZE_LENGTH.VIDEO_DURATION, maxAge: MEMOIZE_TTL.VIDEO_DURATION }) + +// --------------------------------------------------------------------------- + +export function buildCommentsPolicy (options: { + commentsEnabled?: boolean + commentsPolicy?: VideoCommentPolicyType +}) { + if (options.commentsPolicy) return options.commentsPolicy + + if (options.commentsEnabled === true) return VideoCommentPolicy.ENABLED + if (options.commentsEnabled === false) return VideoCommentPolicy.DISABLED + + return CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY +} diff --git a/server/core/middlewares/validators/automatic-tags.ts b/server/core/middlewares/validators/automatic-tags.ts new file mode 100644 index 000000000..378fa8d5d --- /dev/null +++ b/server/core/middlewares/validators/automatic-tags.ts @@ -0,0 +1,45 @@ +import { CommentAutomaticTagPoliciesUpdate } from '@peertube/peertube-models' +import { isStringArray } from '@server/helpers/custom-validators/search.js' +import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js' +import express from 'express' +import { body, param } from 'express-validator' +import { doesAccountNameWithHostExist } from './shared/accounts.js' +import { checkUserCanManageAccount } from './shared/users.js' +import { areValidationErrors } from './shared/utils.js' + +export const manageAccountAutomaticTagsValidator = [ + param('accountName') + .exists(), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + if (!await doesAccountNameWithHostExist(req.params.accountName, res)) return + if (!checkUserCanManageAccount({ user: res.locals.oauth.token.User, account: res.locals.account, specialRight: null, res })) return + + return next() + } +] + +export const updateAutomaticTagPoliciesValidator = [ + ...manageAccountAutomaticTagsValidator, + + body('review') + .custom(isStringArray).withMessage('Should have a valid review array'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const body = req.body as CommentAutomaticTagPoliciesUpdate + + const tagsObj = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account) + const available = new Set(tagsObj.available.map(({ name }) => name)) + + for (const name of body.review) { + if (!available.has(name)) { + return res.fail({ message: `${name} is not an available automatic tag` }) + } + } + + return next() + } +] diff --git a/server/core/middlewares/validators/bulk.ts b/server/core/middlewares/validators/bulk.ts index 3c25757c4..fed338449 100644 --- a/server/core/middlewares/validators/bulk.ts +++ b/server/core/middlewares/validators/bulk.ts @@ -17,7 +17,7 @@ const bulkRemoveCommentsOfValidator = [ const user = res.locals.oauth.token.User const body = req.body as BulkRemoveCommentsOfBody - if (body.scope === 'instance' && user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) !== true) { + if (body.scope === 'instance' && user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) !== true) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'User cannot remove any comments of this instance.' diff --git a/server/core/middlewares/validators/shared/users.ts b/server/core/middlewares/validators/shared/users.ts index 025f2a335..c2a18ffc6 100644 --- a/server/core/middlewares/validators/shared/users.ts +++ b/server/core/middlewares/validators/shared/users.ts @@ -1,20 +1,20 @@ -import express from 'express' +import { forceNumber } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserRightType } from '@peertube/peertube-models' import { ActorModel } from '@server/models/actor/actor.js' import { UserModel } from '@server/models/user/user.js' -import { MUserDefault } from '@server/types/models/index.js' -import { forceNumber } from '@peertube/peertube-core-utils' -import { HttpStatusCode } from '@peertube/peertube-models' +import { MAccountId, MUserAccountId, MUserDefault } from '@server/types/models/index.js' +import express from 'express' -function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) { +export function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) { const id = forceNumber(idArg) return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res) } -function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { +export function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) } -async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) { +export async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) { const user = await UserModel.loadByUsernameOrEmail(username, email) if (user) { @@ -37,7 +37,7 @@ async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: s return true } -async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) { +export async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) { const user = await finder() if (!user) { @@ -55,9 +55,30 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express return true } -export { - checkUserIdExist, - checkUserEmailExist, - checkUserNameOrEmailDoNotAlreadyExist, - checkUserExist +export function checkUserCanManageAccount (options: { + user: MUserAccountId + account: MAccountId + specialRight: UserRightType + res: express.Response +}) { + const { user, account, specialRight, res } = options + + if (account.id === user.Account.id) return true + if (specialRight && user.hasRight(specialRight) === true) return true + + if (!specialRight) { + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: 'Only the owner of this account can manage this account resource.' + }) + + return false + } + + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: 'Only a user with sufficient right can access this account resource.' + }) + + return false } diff --git a/server/core/middlewares/validators/shared/videos.ts b/server/core/middlewares/validators/shared/videos.ts index 65b632cfa..e016fe968 100644 --- a/server/core/middlewares/validators/shared/videos.ts +++ b/server/core/middlewares/validators/shared/videos.ts @@ -287,7 +287,6 @@ function assignVideoTokenIfNeeded (req: Request, res: Response, video: MVideoUUI // --------------------------------------------------------------------------- export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRightType, res: Response, onlyOwned = true) { - // Retrieve the user who did the request if (onlyOwned && video.isOwned() === false) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, @@ -296,9 +295,6 @@ export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, return false } - // Check if the user can delete the video - // The user can delete it if he has the right - // Or if s/he is the video's account const account = video.VideoChannel.Account if (user.hasRight(right) === false && account.userId !== user.id) { res.fail({ diff --git a/server/core/middlewares/validators/sort.ts b/server/core/middlewares/validators/sort.ts index 3f3b2eb4e..777c7cdee 100644 --- a/server/core/middlewares/validators/sort.ts +++ b/server/core/middlewares/validators/sort.ts @@ -30,6 +30,8 @@ export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS. export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS) +export const watchedWordsListsSortValidator = checkSortFactory(SORTABLE_COLUMNS.WATCHED_WORDS_LISTS) + export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) diff --git a/server/core/middlewares/validators/users/users.ts b/server/core/middlewares/validators/users/users.ts index fa047597f..c24f2521d 100644 --- a/server/core/middlewares/validators/users/users.ts +++ b/server/core/middlewares/validators/users/users.ts @@ -34,6 +34,7 @@ import { checkUserEmailExist, checkUserIdExist, checkUserNameOrEmailDoNotAlreadyExist, + checkUserCanManageAccount, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam @@ -421,12 +422,7 @@ const ensureAuthUserOwnsAccountValidator = [ (req: express.Request, res: express.Response, next: express.NextFunction) => { const user = res.locals.oauth.token.User - if (res.locals.account.id !== user.Account.id) { - return res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Only owner of this account can access this resource.' - }) - } + if (!checkUserCanManageAccount({ user, account: res.locals.account, specialRight: null, res })) return return next() } @@ -436,16 +432,8 @@ const ensureCanManageChannelOrAccount = [ (req: express.Request, res: express.Response, next: express.NextFunction) => { const user = res.locals.oauth.token.user const account = res.locals.videoChannel?.Account ?? res.locals.account - const isUserOwner = account.userId === user.id - if (!isUserOwner && user.hasRight(UserRight.MANAGE_ANY_VIDEO_CHANNEL) === false) { - const message = `User ${user.username} does not have right this channel or account.` - - return res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message - }) - } + if (!checkUserCanManageAccount({ account, user, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) return return next() } @@ -461,7 +449,7 @@ const ensureCanModerateUser = [ return res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'A moderator can only manage users.' + message: 'Users can only be managed by moderators or admins.' }) } ] diff --git a/server/core/middlewares/validators/videos/video-comments.ts b/server/core/middlewares/validators/videos/video-comments.ts index 7d92b16f1..836a46854 100644 --- a/server/core/middlewares/validators/videos/video-comments.ts +++ b/server/core/middlewares/validators/videos/video-comments.ts @@ -1,8 +1,19 @@ +import { arrayify } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserRight, VideoCommentPolicy } from '@peertube/peertube-models' +import { isStringArray } from '@server/helpers/custom-validators/search.js' +import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js' +import { MUserAccountUrl } from '@server/types/models/index.js' import express from 'express' import { body, param, query } from 'express-validator' -import { MUserAccountUrl } from '@server/types/models/index.js' -import { HttpStatusCode, UserRight } from '@peertube/peertube-models' -import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc.js' +import { + exists, + isBooleanValid, + isIdOrUUIDValid, + isIdValid, + toBooleanOrNull, + toCompleteUUID, + toIntOrNull +} from '../../../helpers/custom-validators/misc.js' import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments.js' import { logger } from '../../../helpers/logger.js' import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation.js' @@ -11,15 +22,19 @@ import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types import { areValidationErrors, checkCanSeeVideo, + checkUserCanManageAccount, + checkUserCanManageVideo, + doesVideoChannelIdExist, doesVideoCommentExist, doesVideoCommentThreadExist, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared/index.js' -import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js' -const listVideoCommentsValidator = [ +export const listAllVideoCommentsForAdminValidator = [ + ...getCommonVideoCommentsValidators(), + query('isLocal') .optional() .customSanitizer(toBooleanOrNull) @@ -32,26 +47,46 @@ const listVideoCommentsValidator = [ .custom(isBooleanValid) .withMessage('Should have a valid onLocalVideo boolean'), - query('search') - .optional() - .custom(exists), - - query('searchAccount') - .optional() - .custom(exists), - - query('searchVideo') - .optional() - .custom(exists), - - (req: express.Request, res: express.Response, next: express.NextFunction) => { + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return + if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'unsafe-only-immutable-attributes')) return + if (req.query.videoChannelId && !await doesVideoChannelIdExist(req.query.videoChannelId, res)) return + return next() } ] -const listVideoCommentThreadsValidator = [ +export const listCommentsOnUserVideosValidator = [ + ...getCommonVideoCommentsValidators(), + + query('isHeldForReview') + .optional() + .customSanitizer(toBooleanOrNull) + .custom(isBooleanValid) + .withMessage('Should have a valid isHeldForReview boolean'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'all')) return + if (req.query.videoChannelId && !await doesVideoChannelIdExist(req.query.videoChannelId, res)) return + + const user = res.locals.oauth.token.User + + const video = res.locals.videoAll + if (video && !checkUserCanManageVideo(user, video, UserRight.SEE_ALL_COMMENTS, res)) return + + const channel = res.locals.videoChannel + if (channel && !checkUserCanManageAccount({ account: channel.Account, user, res, specialRight: UserRight.SEE_ALL_COMMENTS })) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export const listVideoCommentThreadsValidator = [ isValidVideoIdParam('videoId'), isValidVideoPasswordHeader(), @@ -65,7 +100,7 @@ const listVideoCommentThreadsValidator = [ } ] -const listVideoThreadCommentsValidator = [ +export const listVideoThreadCommentsValidator = [ isValidVideoIdParam('videoId'), param('threadId') @@ -83,7 +118,7 @@ const listVideoThreadCommentsValidator = [ } ] -const addVideoCommentThreadValidator = [ +export const addVideoCommentThreadValidator = [ isValidVideoIdParam('videoId'), body('text') @@ -103,7 +138,7 @@ const addVideoCommentThreadValidator = [ } ] -const addVideoCommentReplyValidator = [ +export const addVideoCommentReplyValidator = [ isValidVideoIdParam('videoId'), param('commentId').custom(isIdValid), @@ -125,7 +160,7 @@ const addVideoCommentReplyValidator = [ } ] -const videoCommentGetValidator = [ +export const videoCommentGetValidator = [ isValidVideoIdParam('videoId'), param('commentId') @@ -143,7 +178,7 @@ const videoCommentGetValidator = [ } ] -const removeVideoCommentValidator = [ +export const removeVideoCommentValidator = [ isValidVideoIdParam('videoId'), param('commentId') @@ -154,29 +189,35 @@ const removeVideoCommentValidator = [ if (!await doesVideoExist(req.params.videoId, res)) return if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return - // Check if the user who did the request is able to delete the video if (!checkUserCanDeleteVideoComment(res.locals.oauth.token.User, res.locals.videoCommentFull, res)) return return next() } ] +export const approveVideoCommentValidator = [ + isValidVideoIdParam('videoId'), + + param('commentId') + .custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.videoId, res)) return + if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return + + if (!checkUserCanApproveVideoComment(res.locals.oauth.token.User, res.locals.videoCommentFull, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- - -export { - listVideoCommentThreadsValidator, - listVideoThreadCommentsValidator, - addVideoCommentThreadValidator, - listVideoCommentsValidator, - addVideoCommentReplyValidator, - videoCommentGetValidator, - removeVideoCommentValidator -} - +// Private // --------------------------------------------------------------------------- function isVideoCommentsEnabled (video: MVideo, res: express.Response) { - if (video.commentsEnabled !== true) { + if (video.commentsPolicy === VideoCommentPolicy.DISABLED) { res.fail({ status: HttpStatusCode.CONFLICT_409, message: 'Video comments are disabled for this video.' @@ -196,10 +237,34 @@ function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MC return false } + return checkUserCanManageVideoComment(user, videoComment, res) +} + +function checkUserCanApproveVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) { + if (videoComment.isDeleted()) { + res.fail({ + status: HttpStatusCode.CONFLICT_409, + message: 'This comment is deleted' + }) + return false + } + + if (videoComment.heldForReview !== true) { + res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'This comment is not held for review' + }) + return false + } + + return checkUserCanManageVideoComment(user, videoComment, res) +} + +function checkUserCanManageVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) { const userAccount = user.Account if ( - user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) === false && // Not a moderator + user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) === false && // Not a moderator videoComment.accountId !== userAccount.id && // Not the comment owner videoComment.Video.VideoChannel.accountId !== userAccount.id // Not the video owner ) { @@ -251,3 +316,34 @@ async function isVideoCommentAccepted (req: express.Request, res: express.Respon return true } + +function getCommonVideoCommentsValidators () { + return [ + query('search') + .optional() + .custom(exists), + + query('searchAccount') + .optional() + .custom(exists), + + query('searchVideo') + .optional() + .custom(exists), + + query('videoId') + .optional() + .custom(toCompleteUUID) + .custom(isIdOrUUIDValid), + + query('videoChannelId') + .optional() + .customSanitizer(toIntOrNull) + .custom(isIdValid), + + query('autoTagOneOf') + .optional() + .customSanitizer(arrayify) + .custom(isStringArray).withMessage('Should have a valid autoTagOneOf array') + ] +} diff --git a/server/core/middlewares/validators/videos/videos.ts b/server/core/middlewares/validators/videos/videos.ts index a3b4793fb..5c2f90d0c 100644 --- a/server/core/middlewares/validators/videos/videos.ts +++ b/server/core/middlewares/validators/videos/videos.ts @@ -23,6 +23,7 @@ import { isScheduleVideoUpdatePrivacyValid, isValidPasswordProtectedPrivacy, isVideoCategoryValid, + isVideoCommentsPolicyValid, isVideoDescriptionValid, isVideoImageValid, isVideoIncludeValid, @@ -375,10 +376,15 @@ function getCommonVideoEditAttributes () { `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` + `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each` ), + // TODO: remove, deprecated in PeerTube 6.2 body('commentsEnabled') .optional() .customSanitizer(toBooleanOrNull) - .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'), + .custom(isBooleanValid).withMessage('Should have valid commentsEnabled boolean'), + body('commentsPolicy') + .optional() + .custom(isVideoCommentsPolicyValid), + body('downloadEnabled') .optional() .customSanitizer(toBooleanOrNull) @@ -462,6 +468,10 @@ const commonVideosFiltersValidator = [ .optional() .customSanitizer(toBooleanOrNull) .isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'), + query('autoTagOneOf') + .optional() + .customSanitizer(arrayify) + .custom(isStringArray).withMessage('Should have a valid autoTagOneOf array'), (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -469,10 +479,10 @@ const commonVideosFiltersValidator = [ const user = res.locals.oauth?.token.User if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) { - if (req.query.include || req.query.privacyOneOf) { + if (req.query.include || req.query.privacyOneOf || req.query.autoTagOneOf) { return res.fail({ status: HttpStatusCode.UNAUTHORIZED_401, - message: 'You are not allowed to see all videos or specify a custom include.' + message: 'You are not allowed to see all videos, specify a custom include or auto tags filter.' }) } } diff --git a/server/core/middlewares/validators/watched-words.ts b/server/core/middlewares/validators/watched-words.ts new file mode 100644 index 000000000..3213d1b12 --- /dev/null +++ b/server/core/middlewares/validators/watched-words.ts @@ -0,0 +1,128 @@ +import { HttpStatusCode } from '@peertube/peertube-models' +import { Awaitable } from '@peertube/peertube-typescript-utils' +import { isIdValid } from '@server/helpers/custom-validators/misc.js' +import { areWatchedWordsValid, isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js' +import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' +import { MAccountId, MWatchedWordsList } from '@server/types/models/index.js' +import express from 'express' +import { ValidationChain, body, param } from 'express-validator' +import { doesAccountNameWithHostExist } from './shared/accounts.js' +import { checkUserCanManageAccount } from './shared/users.js' +import { areValidationErrors } from './shared/utils.js' + +export const manageAccountWatchedWordsListValidator = [ + param('accountName') + .exists(), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + if (!await doesAccountNameWithHostExist(req.params.accountName, res)) return + if (!checkUserCanManageAccount({ user: res.locals.oauth.token.User, account: res.locals.account, specialRight: null, res })) return + + return next() + } +] + +export function getWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) { + return [ + param('listId') + .custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const watchedWordsList = await WatchedWordsListModel.load({ id: +req.params.listId, accountId: (await accountGetter(res)).id }) + if (!watchedWordsList) { + return res.fail({ + status: HttpStatusCode.NOT_FOUND_404, + message: 'Unknown watched words list id for this account' + }) + } + + res.locals.watchedWordsList = watchedWordsList + + return next() + } + ] +} + +function buildUpdateOrAddValidators ({ optional }: { optional: boolean }) { + const makeOptionalIfNeeded = (chain: ValidationChain) => { + if (optional) return chain.optional() + + return chain + } + + return [ + makeOptionalIfNeeded(body('listName')) + .trim() + .custom(isWatchedWordListNameValid).withMessage( + `Should have a list name between ` + + `${CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME.min} and ${CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME.max} characters long` + ), + + makeOptionalIfNeeded(body('words')) + .custom(areWatchedWordsValid) + .withMessage( + `Should have an array of up to ${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORDS.max} words between ` + + `${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD.min} and ${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD.max} characters each` + ) + ] +} + +export function addWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) { + return [ + ...buildUpdateOrAddValidators({ optional: false }), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const listName = req.body.listName + if (!await checkListNameIsUnique({ accountId: (await accountGetter(res)).id, listName, res })) return + + return next() + } + ] +} + +export function updateWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) { + return [ + ...buildUpdateOrAddValidators({ optional: true }), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const currentList = res.locals.watchedWordsList + const listName = req.body.listName + if (listName && !await checkListNameIsUnique({ accountId: (await accountGetter(res)).id, listName, currentList, res })) return + + return next() + } + ] +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function checkListNameIsUnique (options: { + accountId: number + listName: string + res: express.Response + currentList?: MWatchedWordsList +}) { + const { accountId, listName, currentList, res } = options + + const existing = await WatchedWordsListModel.loadByListName({ accountId, listName }) + if (existing && (!currentList || currentList.id !== existing.id)) { + res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: `Watched words list with name ${listName} already exists` + }) + + return false + } + + return true +} diff --git a/server/core/models/account/account.ts b/server/core/models/account/account.ts index 518794ad7..f86a29062 100644 --- a/server/core/models/account/account.ts +++ b/server/core/models/account/account.ts @@ -1,9 +1,10 @@ -import { FindOptions, Includeable, IncludeOptions, Op, Transaction, WhereOptions } from 'sequelize' +import { Account, AccountSummary } from '@peertube/peertube-models' +import { ModelCache } from '@server/models/shared/model-cache.js' +import { FindOptions, IncludeOptions, Includeable, Op, Transaction, WhereOptions } from 'sequelize' import { AllowNull, BeforeDestroy, - BelongsTo, - Column, + BelongsTo, Column, CreatedAt, DataType, Default, @@ -14,8 +15,6 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import { Account, AccountSummary } from '@peertube/peertube-models' -import { ModelCache } from '@server/models/shared/model-cache.js' import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts.js' import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants.js' import { sendDeleteActor } from '../../lib/activitypub/send/send-delete.js' @@ -31,9 +30,12 @@ import { ActorFollowModel } from '../actor/actor-follow.js' import { ActorImageModel } from '../actor/actor-image.js' import { ActorModel } from '../actor/actor.js' import { ApplicationModel } from '../application/application.js' +import { AccountAutomaticTagPolicyModel } from '../automatic-tag/account-automatic-tag-policy.js' +import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js' +import { VideoAutomaticTagModel } from '../automatic-tag/video-automatic-tag.js' import { ServerBlocklistModel } from '../server/server-blocklist.js' import { ServerModel } from '../server/server.js' -import { buildSQLAttributes, getSort, SequelizeModel, throwIfNotValid } from '../shared/index.js' +import { SequelizeModel, buildSQLAttributes, getSort, throwIfNotValid } from '../shared/index.js' import { UserModel } from '../user/user.js' import { VideoChannelModel } from '../video/video-channel.js' import { VideoCommentModel } from '../video/video-comment.js' @@ -232,6 +234,27 @@ export class AccountModel extends SequelizeModel<AccountModel> { }) BlockedBy: Awaited<AccountBlocklistModel>[] + @HasMany(() => AccountAutomaticTagPolicyModel, { + foreignKey: { + name: 'accountId', + allowNull: false + }, + onDelete: 'cascade' + }) + AccountAutomaticTagPolicies: Awaited<AccountAutomaticTagPolicyModel>[] + + @HasMany(() => CommentAutomaticTagModel, { + foreignKey: 'accountId', + onDelete: 'CASCADE' + }) + CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[] + + @HasMany(() => VideoAutomaticTagModel, { + foreignKey: 'accountId', + onDelete: 'CASCADE' + }) + VideoAutomaticTags: Awaited<VideoAutomaticTagModel>[] + @BeforeDestroy static async sendDeleteIfOwned (instance: AccountModel, options) { if (!instance.Actor) { diff --git a/server/core/models/actor/actor-follow.ts b/server/core/models/actor/actor-follow.ts index a2e7311c6..8925a3f6d 100644 --- a/server/core/models/actor/actor-follow.ts +++ b/server/core/models/actor/actor-follow.ts @@ -228,7 +228,7 @@ export class ActorFollowModel extends SequelizeModel<ActorFollowModel> { `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` + `LIMIT 1` - return doesExist(this.sequelize, query, { actorId, followerActorId }) + return doesExist({ sequelize: this.sequelize, query, bind: { actorId, followerActorId } }) } static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> { diff --git a/server/core/models/automatic-tag/account-automatic-tag-policy.ts b/server/core/models/automatic-tag/account-automatic-tag-policy.ts new file mode 100644 index 000000000..141d0028f --- /dev/null +++ b/server/core/models/automatic-tag/account-automatic-tag-policy.ts @@ -0,0 +1,96 @@ +import { type AutomaticTagPolicyType } from '@peertube/peertube-models' +import { MAccountId } from '@server/types/models/index.js' +import { Transaction } from 'sequelize' +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' +import { AccountModel } from '../account/account.js' +import { SequelizeModel, createSafeIn, doesExist } from '../shared/index.js' +import { AutomaticTagModel } from './automatic-tag.js' + +@Table({ + tableName: 'accountAutomaticTagPolicy', + indexes: [ + { + fields: [ 'accountId', 'policy', 'automaticTagId' ], + unique: true + } + ] +}) +export class AccountAutomaticTagPolicyModel extends SequelizeModel<AccountAutomaticTagPolicyModel> { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(true) + @Column(DataType.INTEGER) + policy: AutomaticTagPolicyType + + @ForeignKey(() => AccountModel) + @Column + accountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade' + }) + Account: Awaited<AccountModel> + + @ForeignKey(() => AutomaticTagModel) + @Column + automaticTagId: number + + @BelongsTo(() => AutomaticTagModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade' + }) + AutomaticTag: Awaited<AutomaticTagModel> + + static async listOfAccount (account: MAccountId) { + const rows = await this.findAll({ + where: { accountId: account.id }, + include: [ + { + model: AutomaticTagModel, + required: true + } + ] + }) + + return rows.map(r => ({ name: r.AutomaticTag.name, policy: r.policy })) + } + + static deleteOfAccount (options: { + account: MAccountId + policy: AutomaticTagPolicyType + transaction?: Transaction + }) { + const { account, policy, transaction } = options + + return this.destroy({ + where: { accountId: account.id, policy }, + transaction + }) + } + + static hasPolicyOnTags (options: { + accountId: number + tags: string[] + policy: AutomaticTagPolicyType + transaction: Transaction + }) { + const { accountId, tags, policy, transaction } = options + + const query = `SELECT 1 FROM "accountAutomaticTagPolicy" ` + + `INNER JOIN "automaticTag" ON "automaticTag"."id" = "accountAutomaticTagPolicy"."automaticTagId" ` + + `WHERE "accountId" = $accountId AND "accountAutomaticTagPolicy"."policy" = $policy AND ` + + `"automaticTag"."name" IN (${createSafeIn(this.sequelize, tags)}) ` + + `LIMIT 1` + + return doesExist({ sequelize: this.sequelize, query, bind: { accountId, policy }, transaction }) + } +} diff --git a/server/core/models/automatic-tag/automatic-tag.ts b/server/core/models/automatic-tag/automatic-tag.ts new file mode 100644 index 000000000..5f662f8c4 --- /dev/null +++ b/server/core/models/automatic-tag/automatic-tag.ts @@ -0,0 +1,69 @@ +import { MAutomaticTag } from '@server/types/models/index.js' +import { Transaction, col, fn } from 'sequelize' +import { AllowNull, Column, HasMany, Table } from 'sequelize-typescript' +import { SequelizeModel } from '../shared/index.js' +import { AccountAutomaticTagPolicyModel } from './account-automatic-tag-policy.js' +import { CommentAutomaticTagModel } from './comment-automatic-tag.js' +import { VideoAutomaticTagModel } from './video-automatic-tag.js' + +@Table({ + tableName: 'automaticTag', + timestamps: false, + indexes: [ + { + fields: [ 'name' ], + unique: true + }, + { + name: 'automatic_tag_lower_name', + fields: [ fn('lower', col('name')) ] + } + ] +}) +export class AutomaticTagModel extends SequelizeModel<AutomaticTagModel> { + + @AllowNull(false) + @Column + name: string + + @HasMany(() => CommentAutomaticTagModel, { + foreignKey: 'automaticTagId', + onDelete: 'CASCADE' + }) + CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[] + + @HasMany(() => VideoAutomaticTagModel, { + foreignKey: 'automaticTagId', + onDelete: 'CASCADE' + }) + VideoAutomaticTags: Awaited<VideoAutomaticTagModel>[] + + @HasMany(() => AccountAutomaticTagPolicyModel, { + foreignKey: { + name: 'automaticTagId', + allowNull: false + }, + onDelete: 'cascade' + }) + AccountAutomaticTagPolicies: Awaited<AccountAutomaticTagPolicyModel>[] + + static findOrCreateAutomaticTag (options: { + tag: string + transaction?: Transaction + }): Promise<MAutomaticTag> { + const { tag, transaction } = options + + const query = { + where: { + name: tag + }, + defaults: { + name: tag + }, + transaction + } + + return this.findOrCreate(query) + .then(([ tagInstance ]) => tagInstance) + } +} diff --git a/server/core/models/automatic-tag/comment-automatic-tag.ts b/server/core/models/automatic-tag/comment-automatic-tag.ts new file mode 100644 index 000000000..9c673cb11 --- /dev/null +++ b/server/core/models/automatic-tag/comment-automatic-tag.ts @@ -0,0 +1,76 @@ +import { BelongsTo, Column, CreatedAt, ForeignKey, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript' +import { AccountModel } from '../account/account.js' +import { SequelizeModel } from '../shared/index.js' +import { VideoCommentModel } from '../video/video-comment.js' +import { AutomaticTagModel } from './automatic-tag.js' +import { Transaction } from 'sequelize' + +/** + * + * Sequelize doesn't seem to support many to many relation using BelongsToMany with 3 tables + * So we reproduce the behaviour with classic BelongsTo/HasMany relations + * + */ + +@Table({ + tableName: 'commentAutomaticTag' +}) +export class CommentAutomaticTagModel extends SequelizeModel<CommentAutomaticTagModel> { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => VideoCommentModel) + @PrimaryKey + @Column + commentId: number + + @ForeignKey(() => AutomaticTagModel) + @PrimaryKey + @Column + automaticTagId: number + + @ForeignKey(() => AccountModel) + @PrimaryKey + @Column + accountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Account: Awaited<AccountModel> + + @BelongsTo(() => AutomaticTagModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + AutomaticTag: Awaited<AutomaticTagModel> + + @BelongsTo(() => VideoCommentModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + VideoComment: Awaited<VideoCommentModel> + + static deleteAllOfAccountAndComment (options: { + accountId: number + commentId: number + transaction: Transaction + }) { + const { accountId, commentId, transaction } = options + + return this.destroy({ + where: { accountId, commentId }, + transaction + }) + } +} diff --git a/server/core/models/automatic-tag/video-automatic-tag.ts b/server/core/models/automatic-tag/video-automatic-tag.ts new file mode 100644 index 000000000..d16d3b229 --- /dev/null +++ b/server/core/models/automatic-tag/video-automatic-tag.ts @@ -0,0 +1,76 @@ +import { Transaction } from 'sequelize' +import { BelongsTo, Column, CreatedAt, ForeignKey, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript' +import { AccountModel } from '../account/account.js' +import { SequelizeModel } from '../shared/index.js' +import { VideoModel } from '../video/video.js' +import { AutomaticTagModel } from './automatic-tag.js' + +/** + * + * Sequelize doesn't seem to support many to many relation using BelongsToMany with 3 tables + * So we reproduce the behaviour with classic BelongsTo/HasMany relations + * + */ + +@Table({ + tableName: 'videoAutomaticTag' +}) +export class VideoAutomaticTagModel extends SequelizeModel<VideoAutomaticTagModel> { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => VideoModel) + @PrimaryKey + @Column + videoId: number + + @ForeignKey(() => AutomaticTagModel) + @PrimaryKey + @Column + automaticTagId: number + + @ForeignKey(() => AccountModel) + @PrimaryKey + @Column + accountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Account: Awaited<AccountModel> + + @BelongsTo(() => AutomaticTagModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + AutomaticTag: Awaited<AutomaticTagModel> + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Video: Awaited<VideoModel> + + static deleteAllOfAccountAndVideo (options: { + accountId: number + videoId: number + transaction: Transaction + }) { + const { accountId, videoId, transaction } = options + + return this.destroy({ + where: { accountId, videoId }, + transaction + }) + } +} diff --git a/server/core/models/shared/model-builder.ts b/server/core/models/shared/model-builder.ts index c19ce2d56..26ccfa90f 100644 --- a/server/core/models/shared/model-builder.ts +++ b/server/core/models/shared/model-builder.ts @@ -1,6 +1,6 @@ -import isPlainObject from 'lodash-es/isPlainObject.js' -import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize' import { logger } from '@server/helpers/logger.js' +import isPlainObject from 'lodash-es/isPlainObject.js' +import { ModelStatic, Sequelize, Model as SequelizeModel } from 'sequelize' /** * @@ -49,7 +49,7 @@ export class ModelBuilder <T extends SequelizeModel> { // Child model if (isPlainObject(value)) { - const { created, model: subModel } = this.createModel(value, key, keyPath + '.' + json.id + '.' + key) + const { created, model: subModel } = this.createModel(value, key, `${keyPath}.${json.id}.${key}`) if (!created || !subModel) continue const Model = this.findModelBuilder(modelName) @@ -108,6 +108,7 @@ export class ModelBuilder <T extends SequelizeModel> { if (modelName === 'ActorFollowing') return 'ActorModel' if (modelName === 'ActorFollower') return 'ActorModel' if (modelName === 'FlaggedAccount') return 'AccountModel' + if (modelName === 'CommentAutomaticTags') return 'CommentAutomaticTagModel' return modelName + 'Model' } diff --git a/server/core/models/shared/query.ts b/server/core/models/shared/query.ts index 0158454e8..60dd92b27 100644 --- a/server/core/models/shared/query.ts +++ b/server/core/models/shared/query.ts @@ -1,17 +1,26 @@ -import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize' +import { forceNumber } from '@peertube/peertube-core-utils' +import { BindOrReplacements, Op, QueryOptionsWithType, QueryTypes, Sequelize, Transaction } from 'sequelize' import { Fn } from 'sequelize/types/utils' import validator from 'validator' -import { forceNumber } from '@peertube/peertube-core-utils' -function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) { - const options = { - type: QueryTypes.SELECT as QueryTypes.SELECT, +async function doesExist (options: { + sequelize: Sequelize + query: string + bind?: BindOrReplacements + transaction?: Transaction +}) { + const { sequelize, query, bind, transaction } = options + + const queryOptions: QueryOptionsWithType<QueryTypes.SELECT> = { + type: QueryTypes.SELECT, bind, - raw: true + raw: true, + transaction } - return sequelize.query(query, options) - .then(results => results.length === 1) + const results = await sequelize.query(query, queryOptions) + + return results.length === 1 } // FIXME: have to specify the result type to not break peertube typings generation @@ -64,13 +73,8 @@ function searchAttribute (sourceField?: string, targetField?: string) { } export { - doesExist, - createSimilarityAttribute, - buildWhereIdOrUUID, - parseAggregateResult, - parseRowCountResult, - createSafeIn, - searchAttribute + buildWhereIdOrUUID, createSafeIn, createSimilarityAttribute, doesExist, parseAggregateResult, + parseRowCountResult, searchAttribute } // --------------------------------------------------------------------------- diff --git a/server/core/models/shared/sql.ts b/server/core/models/shared/sql.ts index 3f22dd4e3..b38c8e752 100644 --- a/server/core/models/shared/sql.ts +++ b/server/core/models/shared/sql.ts @@ -4,20 +4,20 @@ import { forceNumber } from '@peertube/peertube-core-utils' import { AttributesOnly } from '@peertube/peertube-typescript-utils' // FIXME: have to specify the result type to not break peertube typings generation -function buildLocalAccountIdsIn (): Literal { +export function buildLocalAccountIdsIn (): Literal { return literal( '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)' ) } // FIXME: have to specify the result type to not break peertube typings generation -function buildLocalActorIdsIn (): Literal { +export function buildLocalActorIdsIn (): Literal { return literal( '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)' ) } -function buildBlockedAccountSQL (blockerIds: number[]) { +export function buildBlockedAccountSQL (blockerIds: number[]) { const blockerIdsString = blockerIds.join(', ') return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + @@ -27,7 +27,7 @@ function buildBlockedAccountSQL (blockerIds: number[]) { 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' } -function buildServerIdsFollowedBy (actorId: any) { +export function buildServerIdsFollowedBy (actorId: any) { const actorIdNumber = forceNumber(actorId) return '(' + @@ -37,18 +37,20 @@ function buildServerIdsFollowedBy (actorId: any) { ')' } -function buildSQLAttributes<M extends Model> (options: { +export function buildSQLAttributes<M extends Model> (options: { model: ModelStatic<M> tableName: string excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[] aliasPrefix?: string + + idBuilder?: string[] }) { - const { model, tableName, aliasPrefix, excludeAttributes } = options + const { model, tableName, aliasPrefix = '', excludeAttributes, idBuilder } = options const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[] - return attributes + const builtAttributes = attributes .filter(a => { if (!excludeAttributes) return true if (excludeAttributes.includes(a)) return false @@ -56,16 +58,15 @@ function buildSQLAttributes<M extends Model> (options: { return true }) .map(a => { - return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"` + return `"${tableName}"."${a}" AS "${aliasPrefix}${a}"` }) -} -// --------------------------------------------------------------------------- + if (idBuilder) { + const idSelect = idBuilder.map(a => `"${tableName}"."${a}"`) + .join(` || '-' || `) -export { - buildSQLAttributes, - buildBlockedAccountSQL, - buildServerIdsFollowedBy, - buildLocalAccountIdsIn, - buildLocalActorIdsIn + builtAttributes.push(`${idSelect} AS "${aliasPrefix}id"`) + } + + return builtAttributes } diff --git a/server/core/models/user/sql/user-notitication-list-query-builder.ts b/server/core/models/user/sql/user-notitication-list-query-builder.ts index ba3a2d70f..32f5ba73e 100644 --- a/server/core/models/user/sql/user-notitication-list-query-builder.ts +++ b/server/core/models/user/sql/user-notitication-list-query-builder.ts @@ -92,6 +92,7 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { "Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host", "VideoComment"."id" AS "VideoComment.id", "VideoComment"."originCommentId" AS "VideoComment.originCommentId", + "VideoComment"."heldForReview" AS "VideoComment.heldForReview", "VideoComment->Account"."id" AS "VideoComment.Account.id", "VideoComment->Account"."name" AS "VideoComment.Account.name", "VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id", diff --git a/server/core/models/user/user-notification.ts b/server/core/models/user/user-notification.ts index 28a5bb894..0b41bf676 100644 --- a/server/core/models/user/user-notification.ts +++ b/server/core/models/user/user-notification.ts @@ -383,7 +383,8 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel> id: this.VideoComment.id, threadId: this.VideoComment.getThreadId(), account: this.formatActor(this.VideoComment.Account), - video: this.formatVideo(this.VideoComment.Video) + video: this.formatVideo(this.VideoComment.Video), + heldForReview: this.VideoComment.heldForReview } : undefined diff --git a/server/core/models/video/formatter/video-activity-pub-format.ts b/server/core/models/video/formatter/video-activity-pub-format.ts index a95fbb3e3..0446b708f 100644 --- a/server/core/models/video/formatter/video-activity-pub-format.ts +++ b/server/core/models/video/formatter/video-activity-pub-format.ts @@ -1,15 +1,16 @@ -import { isArray } from '@server/helpers/custom-validators/misc.js' -import { generateMagnetUri } from '@server/helpers/webtorrent.js' -import { getActivityStreamDuration } from '@server/lib/activitypub/activity.js' -import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js' import { ActivityIconObject, ActivityPlaylistUrlObject, ActivityPubStoryboard, ActivityTagObject, ActivityTrackerUrlObject, - ActivityUrlObject, VideoObject + ActivityUrlObject, VideoCommentPolicy, VideoObject } from '@peertube/peertube-models' +import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js' +import { isArray } from '@server/helpers/custom-validators/misc.js' +import { generateMagnetUri } from '@server/helpers/webtorrent.js' +import { getActivityStreamDuration } from '@server/lib/activitypub/activity.js' +import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js' import { WEBSERVER } from '../../../initializers/constants.js' import { getLocalVideoChaptersActivityPubUrl, @@ -64,7 +65,14 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject { waitTranscoding: video.waitTranscoding, state: video.state, - commentsEnabled: video.commentsEnabled, + + commentsEnabled: video.commentsPolicy !== VideoCommentPolicy.DISABLED, + canReply: video.commentsPolicy === VideoCommentPolicy.ENABLED + ? null + : getAPPublicValue(), // Requires approval + + commentsPolicy: video.commentsPolicy, + downloadEnabled: video.downloadEnabled, published: video.publishedAt.toISOString(), diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index 13035b847..c5f254f17 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -1,6 +1,7 @@ import { Video, VideoAdditionalAttributes, + VideoCommentPolicy, VideoDetails, VideoFile, VideoInclude, @@ -13,7 +14,14 @@ import { tracer } from '@server/lib/opentelemetry/tracing.js' import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js' import { VideoViewsManager } from '@server/lib/views/video-views-manager.js' import { isArray } from '../../../helpers/custom-validators/misc.js' -import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants.js' +import { + VIDEO_CATEGORIES, + VIDEO_COMMENTS_POLICY, + VIDEO_LANGUAGES, + VIDEO_LICENCES, + VIDEO_PRIVACIES, + VIDEO_STATES +} from '../../../initializers/constants.js' import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models/index.js' import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file.js' import { sortByResolutionDesc } from './shared/index.js' @@ -29,6 +37,7 @@ export type VideoFormattingJSONOptions = { files?: boolean source?: boolean blockedOwner?: boolean + automaticTags?: boolean } } @@ -43,7 +52,8 @@ export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfte blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED), files: !!(query.include & VideoInclude.FILES), source: !!(query.include & VideoInclude.SOURCE), - blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER) + blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER), + automaticTags: !!(query.include & VideoInclude.AUTOMATIC_TAGS) } } } @@ -150,7 +160,14 @@ export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetail channel: video.VideoChannel.toFormattedJSON(), account: video.VideoChannel.Account.toFormattedJSON(), tags, - commentsEnabled: video.commentsEnabled, + + // TODO: remove, deprecated in PeerTube 6.2 + commentsEnabled: video.commentsPolicy !== VideoCommentPolicy.DISABLED, + commentsPolicy: { + id: video.commentsPolicy, + label: VIDEO_COMMENTS_POLICY[video.commentsPolicy] + }, + downloadEnabled: video.downloadEnabled, waitTranscoding: video.waitTranscoding, inputFileUpdatedAt: video.inputFileUpdatedAt, @@ -316,5 +333,9 @@ function buildAdditionalAttributes (video: MVideoFormattable, options: VideoForm result.videoSource = video.VideoSource?.toFormattedJSON() || null } + if (add?.automaticTags === true) { + result.automaticTags = (video.VideoAutomaticTags || []).map(t => t.AutomaticTag.name) + } + return result } diff --git a/server/core/models/video/sql/comment/video-comment-list-query-builder.ts b/server/core/models/video/sql/comment/video-comment-list-query-builder.ts index 5e6207056..a3f98e7eb 100644 --- a/server/core/models/video/sql/comment/video-comment-list-query-builder.ts +++ b/server/core/models/video/sql/comment/video-comment-list-query-builder.ts @@ -1,12 +1,14 @@ -import { Model, Sequelize, Transaction } from 'sequelize' -import { AbstractRunQuery, ModelBuilder } from '@server/models/shared/index.js' import { ActorImageType, VideoPrivacy } from '@peertube/peertube-models' +import { AbstractRunQuery, ModelBuilder } from '@server/models/shared/index.js' +import { Model, Sequelize, Transaction } from 'sequelize' import { createSafeIn, getSort, parseRowCountResult } from '../../../shared/index.js' import { VideoCommentTableAttributes } from './video-comment-table-attributes.js' export interface ListVideoCommentsOptions { selectType: 'api' | 'feed' | 'comment-only' + autoTagOfAccountId?: number + start?: number count?: number sort?: string @@ -14,17 +16,24 @@ export interface ListVideoCommentsOptions { videoId?: number threadId?: number accountId?: number - videoChannelId?: number blockerAccountIds?: number[] isThread?: boolean notDeleted?: boolean + isLocal?: boolean onLocalVideo?: boolean + onPublicVideo?: boolean + videoChannelOwnerId?: number videoAccountOwnerId?: number + heldForReview: boolean + heldForReviewAccountIdException?: number + + autoTagOneOf?: string[] + search?: string searchAccount?: string searchVideo?: string @@ -52,7 +61,8 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { accountJoin: false, videoJoin: false, videoChannelJoin: false, - avatarJoin: false + avatarJoin: false, + automaticTagsJoin: false } constructor ( @@ -142,14 +152,6 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { where.push('"VideoCommentModel"."accountId" = :accountId') } - if (this.options.videoChannelId) { - this.buildVideoChannelJoin() - - this.replacements.videoChannelId = this.options.videoChannelId - - where.push('"Account->VideoChannel"."id" = :videoChannelId') - } - if (this.options.blockerAccountIds) { this.buildVideoChannelJoin() @@ -164,6 +166,27 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { where.push('"VideoCommentModel"."deletedAt" IS NULL') } + if (this.options.heldForReview === true) { + where.push('"VideoCommentModel"."heldForReview" IS TRUE') + } else if (this.options.heldForReview === false) { + const base = '"VideoCommentModel"."heldForReview" IS FALSE' + + if (this.options.heldForReviewAccountIdException) { + this.replacements.heldForReviewAccountIdException = this.options.heldForReviewAccountIdException + + where.push(`(${base} OR "VideoCommentModel"."accountId" = :heldForReviewAccountIdException)`) + } else { + where.push(base) + } + } + + if (this.options.autoTagOneOf) { + const tags = this.options.autoTagOneOf.map(t => t.toLowerCase()) + this.buildAutomaticTagsJoin() + + where.push('lower("CommentAutomaticTags->AutomaticTag"."name") IN (' + createSafeIn(this.sequelize, tags) + ')') + } + if (this.options.isLocal === true) { this.buildAccountJoin() @@ -198,6 +221,14 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`) } + if (this.options.videoChannelOwnerId) { + this.buildVideoChannelJoin() + + this.replacements.videoChannelOwnerId = this.options.videoChannelOwnerId + + where.push(`"Video->VideoChannel"."id" = :videoChannelOwnerId`) + } + if (this.options.search) { this.buildVideoJoin() this.buildAccountJoin() @@ -269,7 +300,6 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { } private buildAvatarsJoin () { - if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return '' if (this.built.avatarJoin) return this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` + @@ -279,6 +309,18 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { this.built.avatarJoin = true } + private buildAutomaticTagsJoin () { + if (this.built.automaticTagsJoin) return + + this.innerJoins += 'LEFT JOIN (' + + '"commentAutomaticTag" AS "CommentAutomaticTags" INNER JOIN "automaticTag" AS "CommentAutomaticTags->AutomaticTag" ' + + 'ON "CommentAutomaticTags->AutomaticTag"."id" = "CommentAutomaticTags"."automaticTagId" ' + + ') ON "VideoCommentModel"."id" = "CommentAutomaticTags"."commentId" AND "CommentAutomaticTags"."accountId" = :autoTagOfAccountId' + + this.replacements.autoTagOfAccountId = this.options.autoTagOfAccountId + this.built.automaticTagsJoin = true + } + // --------------------------------------------------------------------------- private buildListSelect () { @@ -308,6 +350,15 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { ]) } + if (this.options.autoTagOfAccountId && this.options.selectType === 'api') { + this.buildAutomaticTagsJoin() + + toSelect = toSelect.concat([ + this.tableAttributes.getCommentAutomaticTagAttributes(), + this.tableAttributes.getAutomaticTagAttributes() + ]) + } + if (this.options.includeReplyCounters === true) { this.buildTotalRepliesSelect() this.buildAuthorTotalRepliesSelect() diff --git a/server/core/models/video/sql/comment/video-comment-table-attributes.ts b/server/core/models/video/sql/comment/video-comment-table-attributes.ts index c7a8a9768..353e5f04f 100644 --- a/server/core/models/video/sql/comment/video-comment-table-attributes.ts +++ b/server/core/models/video/sql/comment/video-comment-table-attributes.ts @@ -1,9 +1,12 @@ import { Memoize } from '@server/helpers/memoize.js' import { AccountModel } from '@server/models/account/account.js' -import { ActorModel } from '@server/models/actor/actor.js' import { ActorImageModel } from '@server/models/actor/actor-image.js' +import { ActorModel } from '@server/models/actor/actor.js' import { ServerModel } from '@server/models/server/server.js' +import { buildSQLAttributes } from '@server/models/shared/sql.js' +import { AutomaticTagModel } from '../../../automatic-tag/automatic-tag.js' import { VideoCommentModel } from '../../video-comment.js' +import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js' export class VideoCommentTableAttributes { @@ -40,4 +43,23 @@ export class VideoCommentTableAttributes { getAvatarAttributes () { return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ') } + + @Memoize() + getCommentAutomaticTagAttributes () { + return buildSQLAttributes({ + model: CommentAutomaticTagModel, + tableName: 'CommentAutomaticTags', + aliasPrefix: 'CommentAutomaticTags.', + idBuilder: [ 'commentId', 'automaticTagId', 'accountId' ] + }).join(', ') + } + + @Memoize() + getAutomaticTagAttributes () { + return buildSQLAttributes({ + model: AutomaticTagModel, + tableName: 'CommentAutomaticTags->AutomaticTag', + aliasPrefix: 'CommentAutomaticTags.AutomaticTag.' + }).join(', ') + } } diff --git a/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts index 79d3feeea..8ba5b0a75 100644 --- a/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts +++ b/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts @@ -259,6 +259,24 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery { } } + protected includeAutomaticTags (autoTagOfAccountId: number) { + this.addJoin( + 'LEFT JOIN (' + + '"videoAutomaticTag" AS "VideoAutomaticTags" INNER JOIN "automaticTag" AS "VideoAutomaticTags->AutomaticTag" ' + + 'ON "VideoAutomaticTags->AutomaticTag"."id" = "VideoAutomaticTags"."automaticTagId" ' + + ') ON "video"."id" = "VideoAutomaticTags"."videoId" AND "VideoAutomaticTags"."accountId" = :autoTagOfAccountId' + ) + + this.replacements.autoTagOfAccountId = autoTagOfAccountId + + this.attributes = { + ...this.attributes, + + ...this.buildAttributesObject('VideoAutomaticTags', this.tables.getVideoAutoTagAttributes()), + ...this.buildAttributesObject('VideoAutomaticTags->AutomaticTag', this.tables.getAutoTagAttributes()) + } + } + protected includeTrackers () { this.addJoin( 'LEFT OUTER JOIN (' + diff --git a/server/core/models/video/sql/video/shared/video-model-builder.ts b/server/core/models/video/sql/video/shared/video-model-builder.ts index 23445f228..cb182a38b 100644 --- a/server/core/models/video/sql/video/shared/video-model-builder.ts +++ b/server/core/models/video/sql/video/shared/video-model-builder.ts @@ -3,6 +3,8 @@ import { AccountBlocklistModel } from '@server/models/account/account-blocklist. import { AccountModel } from '@server/models/account/account.js' import { ActorImageModel } from '@server/models/actor/actor-image.js' import { ActorModel } from '@server/models/actor/actor.js' +import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js' +import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js' import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy.js' import { ServerBlocklistModel } from '@server/models/server/server-blocklist.js' import { ServerModel } from '@server/models/server/server.js' @@ -46,6 +48,7 @@ export class VideoModelBuilder { private trackersDone: Set<string> private tagsDone: Set<string> + private autoTagsDone: Set<string> private videos: VideoModel[] @@ -114,6 +117,10 @@ export class VideoModelBuilder { if (include & VideoInclude.SOURCE) { this.setSource(row, videoModel) } + + if (include & VideoInclude.AUTOMATIC_TAGS) { + this.addAutoTag(row, videoModel) + } } } @@ -142,6 +149,7 @@ export class VideoModelBuilder { this.trackersDone = new Set() this.tagsDone = new Set() + this.autoTagsDone = new Set() this.videos = [] } @@ -184,6 +192,7 @@ export class VideoModelBuilder { videoModel.VideoFiles = [] videoModel.VideoStreamingPlaylists = [] videoModel.Tags = [] + videoModel.VideoAutomaticTags = [] videoModel.Trackers = [] this.buildAccount(row, videoModel) @@ -329,6 +338,23 @@ export class VideoModelBuilder { this.tagsDone.add(key) } + private addAutoTag (row: SQLRow, videoModel: VideoModel) { + if (!row['VideoAutomaticTags.AutomaticTag.id']) return + + const key = `${row['VideoAutomaticTags.videoId']}-${row['VideoAutomaticTags.accountId']}-${row['VideoAutomaticTags.automaticTagId']}` + if (this.autoTagsDone.has(key)) return + + const videoAutomaticTagAttributes = this.grab(row, this.tables.getVideoAutoTagAttributes(), 'VideoAutomaticTags') + const automaticTagModel = new VideoAutomaticTagModel(videoAutomaticTagAttributes, this.buildOpts) + + const automaticTagAttributes = this.grab(row, this.tables.getAutoTagAttributes(), 'VideoAutomaticTags.AutomaticTag') + automaticTagModel.AutomaticTag = new AutomaticTagModel(automaticTagAttributes, this.buildOpts) + + videoModel.VideoAutomaticTags.push(automaticTagModel) + + this.autoTagsDone.add(key) + } + private addTracker (row: SQLRow, videoModel: VideoModel) { if (!row['Trackers.id']) return diff --git a/server/core/models/video/sql/video/shared/video-table-attributes.ts b/server/core/models/video/sql/video/shared/video-table-attributes.ts index f4b06226b..fe84c5d33 100644 --- a/server/core/models/video/sql/video/shared/video-table-attributes.ts +++ b/server/core/models/video/sql/video/shared/video-table-attributes.ts @@ -18,6 +18,7 @@ export class VideoTableAttributes { 'id', 'name', 'description', + 'accountId', 'actorId' ] @@ -196,6 +197,14 @@ export class VideoTableAttributes { ] } + getVideoAutoTagAttributes () { + return [ 'videoId', 'accountId', 'automaticTagId' ] + } + + getAutoTagAttributes () { + return [ 'id', 'name' ] + } + getRedundancyAttributes () { return [ 'id', 'fileUrl' ] } @@ -274,7 +283,7 @@ export class VideoTableAttributes { 'isLive', 'aspectRatio', 'url', - 'commentsEnabled', + 'commentsPolicy', 'downloadEnabled', 'waitTranscoding', 'state', diff --git a/server/core/models/video/sql/video/videos-id-list-query-builder.ts b/server/core/models/video/sql/video/videos-id-list-query-builder.ts index 1a8447b56..1f883eb40 100644 --- a/server/core/models/video/sql/video/videos-id-list-query-builder.ts +++ b/server/core/models/video/sql/video/videos-id-list-query-builder.ts @@ -40,10 +40,14 @@ export type BuildVideosListQueryOptions = { categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] + tagsOneOf?: string[] tagsAllOf?: string[] + privacyOneOf?: VideoPrivacyType[] + autoTagOneOf?: string[] + uuids?: string[] hasFiles?: boolean @@ -197,6 +201,10 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { this.whereTagsAllOf(options.tagsAllOf) } + if (options.autoTagOneOf) { + this.whereAutoTagOneOf(options.autoTagOneOf) + } + if (options.privacyOneOf) { this.wherePrivacyOneOf(options.privacyOneOf) } else { @@ -448,6 +456,20 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { this.joins.push('INNER JOIN "tagsOneOf" ON "video"."id" = "tagsOneOf"."videoId"') } + private whereAutoTagOneOf (autoTagOneOf: string[]) { + const tags = autoTagOneOf.map(t => t.toLowerCase()) + + this.cte.push( + '"autoTagsOneOf" AS (' + + ' SELECT "videoAutomaticTag"."videoId" AS "videoId" FROM "videoAutomaticTag" ' + + ' INNER JOIN "automaticTag" ON "automaticTag"."id" = "videoAutomaticTag"."automaticTagId" ' + + ' WHERE lower("automaticTag"."name") IN (' + createSafeIn(this.sequelize, tags) + ') ' + + ')' + ) + + this.joins.push('INNER JOIN "autoTagsOneOf" ON "video"."id" = "autoTagsOneOf"."videoId"') + } + private whereTagsAllOf (tagsAllOf: string[]) { const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase()) diff --git a/server/core/models/video/sql/video/videos-model-list-query-builder.ts b/server/core/models/video/sql/video/videos-model-list-query-builder.ts index 2dc83e18e..dc5df090a 100644 --- a/server/core/models/video/sql/video/videos-model-list-query-builder.ts +++ b/server/core/models/video/sql/video/videos-model-list-query-builder.ts @@ -5,6 +5,8 @@ import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder import { VideoFileQueryBuilder } from './shared/video-file-query-builder.js' import { VideoModelBuilder } from './shared/video-model-builder.js' import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder.js' +import { getServerActor } from '@server/models/application/application.js' +import { MActorAccount } from '@server/types/models/index.js' /** * @@ -32,8 +34,10 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder { } async queryVideos (options: BuildVideosListQueryOptions) { + const serverActor = await getServerActor() + this.buildInnerQuery(options) - this.buildMainQuery(options) + this.buildMainQuery(options, serverActor) const rows = await this.runQuery() @@ -69,7 +73,7 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder { this.innerSort = sort } - private buildMainQuery (options: BuildVideosListQueryOptions) { + private buildMainQuery (options: BuildVideosListQueryOptions, serverActor: MActorAccount) { this.attributes = { '"video".*': '' } @@ -100,6 +104,10 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder { this.includeVideoSource() } + if (options.include & VideoInclude.AUTOMATIC_TAGS) { + this.includeAutomaticTags(serverActor.Account.id) + } + const select = this.buildSelect() this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}` diff --git a/server/core/models/video/tag.ts b/server/core/models/video/tag.ts index 9d5e8b793..6dc53ae78 100644 --- a/server/core/models/video/tag.ts +++ b/server/core/models/video/tag.ts @@ -1,7 +1,7 @@ -import { col, fn, QueryTypes, Transaction } from 'sequelize' -import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Table, UpdatedAt } from 'sequelize-typescript' import { VideoPrivacy, VideoState } from '@peertube/peertube-models' -import { MTag } from '@server/types/models/index.js' +import { MTag } from '@server/types/models/video/tag.js' +import { QueryTypes, Transaction, col, fn } from 'sequelize' +import { AllowNull, BelongsToMany, Column, Is, Table } from 'sequelize-typescript' import { isVideoTagValid } from '../../helpers/custom-validators/videos.js' import { SequelizeModel, throwIfNotValid } from '../shared/index.js' import { VideoTagModel } from './video-tag.js' @@ -28,12 +28,6 @@ export class TagModel extends SequelizeModel<TagModel> { @Column name: string - @CreatedAt - createdAt: Date - - @UpdatedAt - updatedAt: Date - @BelongsToMany(() => VideoModel, { foreignKey: 'tagId', through: () => VideoTagModel, @@ -41,29 +35,6 @@ export class TagModel extends SequelizeModel<TagModel> { }) Videos: Awaited<VideoModel>[] - static findOrCreateTags (tags: string[], transaction: Transaction): Promise<MTag[]> { - if (tags === null) return Promise.resolve([]) - - const uniqueTags = new Set(tags) - - const tasks = Array.from(uniqueTags).map(tag => { - const query = { - where: { - name: tag - }, - defaults: { - name: tag - }, - transaction - } - - return TagModel.findOrCreate<MTag>(query) - .then(([ tagInstance ]) => tagInstance) - }) - - return Promise.all(tasks) - } - // threshold corresponds to how many video the field should have to be returned static getRandomSamples (threshold: number, count: number): Promise<string[]> { const query = 'SELECT tag.name FROM tag ' + @@ -82,4 +53,32 @@ export class TagModel extends SequelizeModel<TagModel> { return TagModel.sequelize.query<{ name: string }>(query, options) .then(data => data.map(d => d.name)) } + + static findOrCreateMultiple (options: { + tags: string[] + transaction?: Transaction + }): Promise<MTag[]> { + const { tags, transaction } = options + + if (tags === null) return Promise.resolve([]) + + const uniqueTags = new Set(tags) + + const tasks = Array.from(uniqueTags).map(tag => { + const query = { + where: { + name: tag + }, + defaults: { + name: tag + }, + transaction + } + + return this.findOrCreate(query) + .then(([ tagInstance ]) => tagInstance) + }) + + return Promise.all(tasks) + } } diff --git a/server/core/models/video/video-comment.ts b/server/core/models/video/video-comment.ts index d41d7974b..bf02a1c39 100644 --- a/server/core/models/video/video-comment.ts +++ b/server/core/models/video/video-comment.ts @@ -1,13 +1,20 @@ import { pick } from '@peertube/peertube-core-utils' -import { ActivityTagObject, ActivityTombstoneObject, VideoComment, VideoCommentAdmin, VideoCommentObject } from '@peertube/peertube-models' +import { + ActivityTagObject, + ActivityTombstoneObject, + UserRight, + VideoComment, + VideoCommentForAdminOrUser, + VideoCommentObject +} from '@peertube/peertube-models' import { extractMentions } from '@server/helpers/mentions.js' +import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js' import { getServerActor } from '@server/models/application/application.js' import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js' import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize' import { AllowNull, - BelongsTo, - Column, + BelongsTo, Column, CreatedAt, DataType, ForeignKey, @@ -21,20 +28,20 @@ import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/co import { MComment, MCommentAP, - MCommentAdminFormattable, + MCommentAdminOrUserFormattable, MCommentExport, MCommentFormattable, MCommentId, MCommentOwner, - MCommentOwnerReplyVideoLight, - MCommentOwnerVideo, - MCommentOwnerVideoFeed, + MCommentOwnerReplyVideoImmutable, MCommentOwnerVideoFeed, MCommentOwnerVideoReply, + MVideo, MVideoImmutable } from '../../types/models/video/index.js' import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js' import { AccountModel } from '../account/account.js' import { ActorModel } from '../actor/actor.js' +import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js' import { SequelizeModel, buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared/index.js' import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js' import { VideoChannelModel } from './video-channel.js' @@ -140,6 +147,14 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { @Column(DataType.TEXT) text: string + @AllowNull(false) + @Column + heldForReview: boolean + + @AllowNull(true) + @Column + replyApproval: string + @ForeignKey(() => VideoCommentModel) @Column originCommentId: number @@ -201,6 +216,12 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { }) CommentAbuses: Awaited<VideoCommentAbuseModel>[] + @HasMany(() => CommentAutomaticTagModel, { + foreignKey: 'commentId', + onDelete: 'CASCADE' + }) + CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[] + // --------------------------------------------------------------------------- static getSQLAttributes (tableName: string, aliasPrefix = '') { @@ -237,7 +258,9 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { .findOne(query) } - static loadByUrlAndPopulateAccountAndVideo (url: string, transaction?: Transaction): Promise<MCommentOwnerVideo> { + // --------------------------------------------------------------------------- + + static loadByUrlAndPopulateAccountAndVideoAndReply (url: string, transaction?: Transaction): Promise<MCommentOwnerVideoReply> { const query = { where: { url @@ -245,17 +268,20 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { transaction } - return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query) + return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO, ScopeNames.WITH_IN_REPLY_TO ]).findOne(query) } - static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, transaction?: Transaction): Promise<MCommentOwnerReplyVideoLight> { + static loadByUrlAndPopulateReplyAndVideoImmutableAndAccount ( + url: string, + transaction?: Transaction + ): Promise<MCommentOwnerReplyVideoImmutable> { const query = { where: { url }, include: [ { - attributes: [ 'id', 'url' ], + attributes: [ 'id', 'uuid', 'url', 'remote' ], model: VideoModel.unscoped() } ], @@ -265,26 +291,56 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query) } + // --------------------------------------------------------------------------- + static listCommentsForApi (parameters: { start: number count: number sort: string + autoTagOfAccountId: number + + videoAccountOwnerId?: number + videoChannelOwnerId?: number + onLocalVideo?: boolean isLocal?: boolean + search?: string searchAccount?: string searchVideo?: string + + heldForReview: boolean + + videoId?: number + videoChannelId?: number + autoTagOneOf?: string[] }) { const queryOptions: ListVideoCommentsOptions = { - ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]), + ...pick(parameters, [ + 'start', + 'count', + 'sort', + 'isLocal', + 'search', + 'searchVideo', + 'searchAccount', + 'onLocalVideo', + 'videoId', + 'videoChannelId', + 'autoTagOneOf', + 'autoTagOfAccountId', + 'videoAccountOwnerId', + 'videoChannelOwnerId', + 'heldForReview' + ]), selectType: 'api', notDeleted: true } return Promise.all([ - new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(), + new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminOrUserFormattable>(), new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments() ]).then(([ rows, count ]) => { return { total: count, data: rows } @@ -292,21 +348,25 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { } static async listThreadsForApi (parameters: { - videoId: number - isVideoOwned: boolean + video: MVideo start: number count: number sort: string user?: MUserAccountId }) { - const { videoId, user } = parameters + const { video, user } = parameters - const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user }) + const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video }) const commonOptions: ListVideoCommentsOptions = { selectType: 'api', - videoId, - blockerAccountIds + videoId: video.id, + blockerAccountIds, + + heldForReview: canSeeHeldForReview + ? undefined // Display all comments for video owner or moderator + : false, + heldForReviewAccountIdException: user?.Account?.id } const listOptions: ListVideoCommentsOptions = { @@ -330,7 +390,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { } return Promise.all([ - new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(), + new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminOrUserFormattable>(), new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(), new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments() ]).then(([ rows, count, totalNotDeletedComments ]) => { @@ -339,33 +399,45 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { } static async listThreadCommentsForApi (parameters: { - videoId: number + video: MVideo threadId: number user?: MUserAccountId }) { - const { user } = parameters + const { user, video, threadId } = parameters - const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user }) + const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video }) const queryOptions: ListVideoCommentsOptions = { - ...pick(parameters, [ 'videoId', 'threadId' ]), + threadId, + videoId: video.id, selectType: 'api', sort: 'createdAt', blockerAccountIds, - includeReplyCounters: true + includeReplyCounters: true, + + heldForReview: canSeeHeldForReview + ? undefined // Display all comments for video owner or moderator + : false, + heldForReviewAccountIdException: user?.Account?.id } return Promise.all([ - new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(), + new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminOrUserFormattable>(), new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments() ]).then(([ rows, count ]) => { return { total: count, data: rows } }) } - static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { + static listThreadParentComments (options: { + comment: MCommentId + transaction?: Transaction + order?: 'ASC' | 'DESC' + }): Promise<MCommentOwner[]> { + const { comment, transaction, order = 'ASC' } = options + const query = { order: [ [ 'createdAt', order ] ] as Order, where: { @@ -382,7 +454,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { [Op.ne]: comment.id } }, - transaction: t + transaction } return VideoCommentModel @@ -406,6 +478,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { videoId: video.id, sort: 'createdAt', + heldForReview: false, + blockerAccountIds } @@ -421,19 +495,21 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { start: number count: number videoId?: number - accountId?: number - videoChannelId?: number + videoAccountOwnerId?: number + videoChannelOwnerId?: number }) { const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null }) const queryOptions: ListVideoCommentsOptions = { - ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]), + ...pick(parameters, [ 'start', 'count', 'videoAccountOwnerId', 'videoId', 'videoChannelOwnerId' ]), selectType: 'feed', sort: '-createdAt', onPublicVideo: true, + notDeleted: true, + heldForReview: false, blockerAccountIds } @@ -448,6 +524,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { accountId: ofAccount.id, videoAccountOwnerId: filter.onVideosOfAccount?.id, + heldForReview: undefined, + notDeleted: true, count: 5000 } @@ -457,14 +535,14 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { static listForExport (ofAccountId: number): Promise<MCommentExport[]> { return VideoCommentModel.findAll({ - attributes: [ 'url', 'text', 'createdAt' ], + attributes: [ 'id', 'url', 'text', 'createdAt' ], where: { accountId: ofAccountId, deletedAt: null }, include: [ { - attributes: [ 'url' ], + attributes: [ 'id', 'uuid', 'url' ], required: true, model: VideoModel.unscoped() }, @@ -479,9 +557,12 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { }) } + // --------------------------------------------------------------------------- + static async getStats () { const where = { - deletedAt: null + deletedAt: null, + heldForReview: false } const totalLocalVideoComments = await VideoCommentModel.count({ @@ -510,6 +591,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { } } + // --------------------------------------------------------------------------- + static listRemoteCommentUrlsOfLocalVideos () { const query = `SELECT "videoComment".url FROM "videoComment" ` + `INNER JOIN account ON account.id = "videoComment"."accountId" ` + @@ -540,10 +623,16 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { return VideoCommentModel.destroy(query) } + // --------------------------------------------------------------------------- + getCommentStaticPath () { return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId() } + getCommentUserReviewPath () { + return '/my-account/videos/comments?search=heldForReview:true' + } + getThreadId (): number { return this.originCommentId || this.id } @@ -582,6 +671,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { updatedAt: this.updatedAt, deletedAt: this.deletedAt, + heldForReview: this.heldForReview, + isDeleted: this.isDeleted(), totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0, @@ -593,7 +684,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { } as VideoComment } - toFormattedAdminJSON (this: MCommentAdminFormattable) { + toFormattedForAdminOrUserJSON (this: MCommentAdminOrUserFormattable) { return { id: this.id, url: this.url, @@ -606,6 +697,9 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { createdAt: this.createdAt, updatedAt: this.updatedAt, + heldForReview: this.heldForReview, + automaticTags: (this.CommentAutomaticTags || []).map(m => m.AutomaticTag.name), + video: { id: this.Video.id, uuid: this.Video.uuid, @@ -615,17 +709,13 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { account: this.Account ? this.Account.toFormattedJSON() : null - } as VideoCommentAdmin + } as VideoCommentForAdminOrUser } toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject { - let inReplyTo: string - // New thread, so in AS we reply to the video - if (this.inReplyToCommentId === null) { - inReplyTo = this.Video.url - } else { - inReplyTo = this.InReplyToVideoComment.url - } + const inReplyTo = this.inReplyToCommentId === null + ? this.Video.url // New thread, so we reply to the video + : this.InReplyToVideoComment.url if (this.isDeleted()) { return { @@ -652,6 +742,11 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { }) } + let replyApproval = this.replyApproval + if (this.Video.isOwned() && !this.heldForReview) { + replyApproval = getLocalApproveReplyActivityPubUrl(this.Video, this) + } + return { type: 'Note' as 'Note', id: this.url, @@ -664,6 +759,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { published: this.createdAt.toISOString(), url: this.url, attributedTo: this.Account.Actor.url, + replyApproval, tag } } @@ -680,4 +776,27 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> { return blockerAccountIds } + + private static buildBlockerAccountIdsAndCanSeeHeldForReview (options: { + video: MVideo + user: MUserAccountId + }) { + const { video, user } = options + const blockerAccountIdsPromise = this.buildBlockerAccountIds(options) + + let canSeeHeldForReviewPromise: Promise<boolean> + if (user) { + if (user.hasRight(UserRight.SEE_ALL_COMMENTS)) { + canSeeHeldForReviewPromise = Promise.resolve(true) + } else { + canSeeHeldForReviewPromise = VideoChannelModel.loadAndPopulateAccount(video.channelId) + .then(c => c.accountId === user.Account.id) + } + } else { + canSeeHeldForReviewPromise = Promise.resolve(false) + } + + return Promise.all([ blockerAccountIdsPromise, canSeeHeldForReviewPromise ]) + .then(([ blockerAccountIds, canSeeHeldForReview ]) => ({ blockerAccountIds, canSeeHeldForReview })) + } } diff --git a/server/core/models/video/video-file.ts b/server/core/models/video/video-file.ts index dfb7eaedb..9c2b03519 100644 --- a/server/core/models/video/video-file.ts +++ b/server/core/models/video/video-file.ts @@ -270,7 +270,7 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> { static doesInfohashExist (infoHash: string) { const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' - return doesExist(this.sequelize, query, { infoHash }) + return doesExist({ sequelize: this.sequelize, query, bind: { infoHash } }) } static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { @@ -286,14 +286,14 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> { 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' + 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webvideo"."id" IS NOT NULL) LIMIT 1' - return doesExist(this.sequelize, query, { filename }) + return doesExist({ sequelize: this.sequelize, query, bind: { filename } }) } static async doesOwnedWebVideoFileExist (filename: string) { const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' + `WHERE "filename" = $filename AND "storage" = ${FileStorage.FILE_SYSTEM} LIMIT 1` - return doesExist(this.sequelize, query, { filename }) + return doesExist({ sequelize: this.sequelize, query, bind: { filename } }) } static loadByFilename (filename: string) { diff --git a/server/core/models/video/video-streaming-playlist.ts b/server/core/models/video/video-streaming-playlist.ts index c5d107e23..446c3b810 100644 --- a/server/core/models/video/video-streaming-playlist.ts +++ b/server/core/models/video/video-streaming-playlist.ts @@ -136,7 +136,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl // Don't add a LIMIT 1 here to prevent seq scan by PostgreSQL (not sure why id doesn't use the index when we add a LIMIT) const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE "p2pMediaLoaderInfohashes" @> $infoHash' - return doesExist(this.sequelize, query, { infoHash: `{${infoHash}}` }) // Transform infoHash in a PG array + return doesExist({ sequelize: this.sequelize, query, bind: { infoHash: `{${infoHash}}` } }) // Transform infoHash in a PG array } static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) { @@ -235,7 +235,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` + `AND "storage" = ${FileStorage.FILE_SYSTEM} LIMIT 1` - return doesExist(this.sequelize, query, { videoUUID }) + return doesExist({ sequelize: this.sequelize, query, bind: { videoUUID } }) } assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) { diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index 9f43a75a2..ec96df0ed 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -15,6 +15,7 @@ import { VideoRateType, VideoState, VideoStreamingPlaylistType, + type VideoCommentPolicyType, type VideoPrivacyType, type VideoStateType } from '@peertube/peertube-models' @@ -111,6 +112,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate.js' import { AccountModel } from '../account/account.js' import { ActorImageModel } from '../actor/actor-image.js' import { ActorModel } from '../actor/actor.js' +import { VideoAutomaticTagModel } from '../automatic-tag/video-automatic-tag.js' import { VideoRedundancyModel } from '../redundancy/video-redundancy.js' import { ServerModel } from '../server/server.js' import { TrackerModel } from '../server/tracker.js' @@ -549,7 +551,7 @@ export class VideoModel extends SequelizeModel<VideoModel> { @AllowNull(false) @Column - commentsEnabled: boolean + commentsPolicy: VideoCommentPolicyType @AllowNull(false) @Column @@ -777,6 +779,12 @@ export class VideoModel extends SequelizeModel<VideoModel> { }) VideoPasswords: Awaited<VideoPasswordModel>[] + @HasMany(() => VideoAutomaticTagModel, { + foreignKey: 'videoId', + onDelete: 'CASCADE' + }) + VideoAutomaticTags: Awaited<VideoAutomaticTagModel>[] + @HasOne(() => VideoJobInfoModel, { foreignKey: { name: 'videoId', @@ -1172,6 +1180,8 @@ export class VideoModel extends SequelizeModel<VideoModel> { search?: string excludeAlreadyWatched?: boolean + + autoTagOneOf?: string[] }) { VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) @@ -1196,6 +1206,7 @@ export class VideoModel extends SequelizeModel<VideoModel> { 'categoryOneOf', 'licenceOneOf', 'languageOneOf', + 'autoTagOneOf', 'tagsOneOf', 'tagsAllOf', 'privacyOneOf', @@ -1264,6 +1275,8 @@ export class VideoModel extends SequelizeModel<VideoModel> { excludeAlreadyWatched?: boolean countVideos?: boolean + + autoTagOneOf?: string[] }) { VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) @@ -1278,6 +1291,7 @@ export class VideoModel extends SequelizeModel<VideoModel> { 'categoryOneOf', 'licenceOneOf', 'languageOneOf', + 'autoTagOneOf', 'tagsOneOf', 'tagsAllOf', 'privacyOneOf', diff --git a/server/core/models/watched-words/watched-words-list.ts b/server/core/models/watched-words/watched-words-list.ts new file mode 100644 index 000000000..c579e50f4 --- /dev/null +++ b/server/core/models/watched-words/watched-words-list.ts @@ -0,0 +1,206 @@ +import { WatchedWordsList } from '@peertube/peertube-models' +import { logger } from '@server/helpers/logger.js' +import { wordsToRegExp } from '@server/helpers/regexp.js' +import { MAccountId, MWatchedWordsList } from '@server/types/models/index.js' +import { LRUCache } from 'lru-cache' +import { Transaction } from 'sequelize' +import { + AllowNull, + BelongsTo, + Column, + CreatedAt, + DataType, ForeignKey, Table, + UpdatedAt +} from 'sequelize-typescript' +import { LRU_CACHE, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js' +import { AccountModel } from '../account/account.js' +import { SequelizeModel, getSort } from '../shared/index.js' + +@Table({ + tableName: 'watchedWordsList', + indexes: [ + { + fields: [ 'listName', 'accountId' ], + unique: true + }, + { + fields: [ 'accountId' ] + } + ] +}) +export class WatchedWordsListModel extends SequelizeModel<WatchedWordsListModel> { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @Column + listName: string + + @AllowNull(false) + @Column(DataType.ARRAY(DataType.STRING)) + words: string[] + + @ForeignKey(() => AccountModel) + @Column + accountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Account: Awaited<AccountModel> + + // accountId => reg expressions + private static readonly regexCache = new LRUCache<number, { listName: string, regex: RegExp }[]>({ + max: LRU_CACHE.WATCHED_WORDS_REGEX.MAX_SIZE, + ttl: LRU_CACHE.WATCHED_WORDS_REGEX.TTL + }) + + static load (options: { + id: number + accountId: number + }) { + const { id, accountId } = options + + const query = { + where: { id, accountId } + } + + return this.findOne(query) + } + + static loadByListName (options: { + listName: string + accountId: number + }) { + const { listName, accountId } = options + + const query = { + where: { listName, accountId } + } + + return this.findOne(query) + } + + // --------------------------------------------------------------------------- + + static listNamesOf (account: MAccountId) { + const query = { + raw: true, + attributes: [ 'listName' ], + where: { accountId: account.id } + } + + return WatchedWordsListModel.findAll(query) + .then(rows => rows.map(r => r.listName)) + } + + static listForAPI (options: { + accountId: number + start: number + count: number + sort: string + }) { + const { accountId, start, count, sort } = options + + const query = { + offset: start, + limit: count, + order: getSort(sort), + where: { accountId } + } + + return Promise.all([ + WatchedWordsListModel.count(query), + WatchedWordsListModel.findAll(query) + ]).then(([ total, data ]) => ({ total, data })) + } + + static listForExport (options: { + accountId: number + }) { + const { accountId } = options + + return WatchedWordsListModel.findAll({ + limit: USER_EXPORT_MAX_ITEMS, + order: getSort('createdAt'), + where: { accountId } + }) + } + + // --------------------------------------------------------------------------- + + static async buildWatchedWordsRegexp (options: { + accountId: number + transaction: Transaction + }) { + const { accountId, transaction } = options + + if (WatchedWordsListModel.regexCache.has(accountId)) { + return WatchedWordsListModel.regexCache.get(accountId) + } + + const models = await WatchedWordsListModel.findAll<MWatchedWordsList>({ + where: { accountId }, + transaction + }) + + const result = models.map(m => ({ listName: m.listName, regex: wordsToRegExp(m.words) })) + + this.regexCache.set(accountId, result) + + logger.debug('Will cache watched words regex', { accountId, result, tags: [ 'watched-words' ] }) + + return result + } + + static createList (options: { + accountId: number + + listName: string + words: string[] + }) { + WatchedWordsListModel.regexCache.delete(options.accountId) + + return super.create(options) + } + + updateList (options: { + listName: string + words?: string[] + }) { + const { listName, words } = options + + if (words && words.length === 0) { + throw new Error('Cannot update watched words with an empty list') + } + + if (words) this.words = words + if (listName) this.listName = listName + + WatchedWordsListModel.regexCache.delete(this.accountId) + + return this.save() + } + + destroy () { + WatchedWordsListModel.regexCache.delete(this.accountId) + + return super.destroy() + } + + toFormattedJSON (): WatchedWordsList { + return { + id: this.id, + listName: this.listName, + words: this.words, + updatedAt: this.updatedAt, + createdAt: this.createdAt + } + } +} diff --git a/server/core/types/express.d.ts b/server/core/types/express.d.ts index 8bd3a829a..a1455c4a0 100644 --- a/server/core/types/express.d.ts +++ b/server/core/types/express.d.ts @@ -21,7 +21,8 @@ import { MVideoPassword, MVideoPlaylistFull, MVideoPlaylistFullSummary, - MVideoThumbnailBlacklist + MVideoThumbnailBlacklist, + MWatchedWordsList } from '@server/types/models/index.js' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js' import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server.js' @@ -231,6 +232,8 @@ declare module 'express' { runnerJob?: MRunnerJobRunner userExport?: MUserExport + + watchedWordsList?: MWatchedWordsList } } } diff --git a/server/core/types/models/automatic-tag/account-automatic-tag-policy.ts b/server/core/types/models/automatic-tag/account-automatic-tag-policy.ts new file mode 100644 index 000000000..7f6ea518a --- /dev/null +++ b/server/core/types/models/automatic-tag/account-automatic-tag-policy.ts @@ -0,0 +1,3 @@ +import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js' + +export type MAccountAutomaticTagPolicy = Omit<AccountAutomaticTagPolicyModel, 'Account' | 'AutomaticTag'> diff --git a/server/core/types/models/automatic-tag/automatic-tag.ts b/server/core/types/models/automatic-tag/automatic-tag.ts new file mode 100644 index 000000000..41fb9a071 --- /dev/null +++ b/server/core/types/models/automatic-tag/automatic-tag.ts @@ -0,0 +1,3 @@ +import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js' + +export type MAutomaticTag = Omit<AutomaticTagModel, 'Videos' | 'VideoComments'> diff --git a/server/core/types/models/automatic-tag/comment-automatic-tag.ts b/server/core/types/models/automatic-tag/comment-automatic-tag.ts new file mode 100644 index 000000000..13ff9af79 --- /dev/null +++ b/server/core/types/models/automatic-tag/comment-automatic-tag.ts @@ -0,0 +1,15 @@ +import { PickWith } from '@peertube/peertube-typescript-utils' +import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js' +import { MAutomaticTag } from './automatic-tag.js' + +type Use<K extends keyof CommentAutomaticTagModel, M> = PickWith<CommentAutomaticTagModel, K, M> + +// ############################################################################ + +export type MCommentAutomaticTag = Omit<CommentAutomaticTagModel, 'Account' | 'VideoComment' | 'AutomaticTag'> + +// ############################################################################ + +export type MCommentAutomaticTagWithTag = + MCommentAutomaticTag & + Use<'AutomaticTag', MAutomaticTag> diff --git a/server/core/types/models/automatic-tag/index.ts b/server/core/types/models/automatic-tag/index.ts new file mode 100644 index 000000000..e10f99b8b --- /dev/null +++ b/server/core/types/models/automatic-tag/index.ts @@ -0,0 +1,4 @@ +export * from './account-automatic-tag-policy.js' +export * from './automatic-tag.js' +export * from './comment-automatic-tag.js' +export * from './video-automatic-tag.js' diff --git a/server/core/types/models/automatic-tag/video-automatic-tag.ts b/server/core/types/models/automatic-tag/video-automatic-tag.ts new file mode 100644 index 000000000..7d8d53694 --- /dev/null +++ b/server/core/types/models/automatic-tag/video-automatic-tag.ts @@ -0,0 +1,15 @@ +import { PickWith } from '@peertube/peertube-typescript-utils' +import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js' +import { MAutomaticTag } from './automatic-tag.js' + +type Use<K extends keyof VideoAutomaticTagModel, M> = PickWith<VideoAutomaticTagModel, K, M> + +// ############################################################################ + +export type MVideoAutomaticTag = Omit<VideoAutomaticTagModel, 'Account' | 'Video' | 'AutomaticTag'> + +// ############################################################################ + +export type MVideoAutomaticTagWithTag = + MVideoAutomaticTag & + Use<'AutomaticTag', MAutomaticTag> diff --git a/server/core/types/models/index.ts b/server/core/types/models/index.ts index 8c90db53c..eccab5c81 100644 --- a/server/core/types/models/index.ts +++ b/server/core/types/models/index.ts @@ -2,7 +2,9 @@ export * from './abuse/index.js' export * from './account/index.js' export * from './actor/index.js' export * from './application/index.js' +export * from './automatic-tag/index.js' export * from './oauth/index.js' export * from './server/index.js' export * from './user/index.js' export * from './video/index.js' +export * from './watched-words/index.js' diff --git a/server/core/types/models/user/user-notification.ts b/server/core/types/models/user/user-notification.ts index 5a0b57e7a..ca19f8f09 100644 --- a/server/core/types/models/user/user-notification.ts +++ b/server/core/types/models/user/user-notification.ts @@ -45,7 +45,7 @@ export module UserNotificationIncludes { PickWith<AccountModel, 'Actor', ActorInclude> export type VideoCommentInclude = - Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId'> & + Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId' | 'heldForReview'> & PickWith<VideoCommentModel, 'Account', AccountIncludeActor> & PickWith<VideoCommentModel, 'Video', VideoInclude> diff --git a/server/core/types/models/video/video-channel.ts b/server/core/types/models/video/video-channel.ts index b3a74aa53..d29090f95 100644 --- a/server/core/types/models/video/video-channel.ts +++ b/server/core/types/models/video/video-channel.ts @@ -1,11 +1,10 @@ import { FunctionProperties, PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils' import { VideoChannelModel } from '../../../models/video/video-channel.js' import { - MAccountActor, MAccountAPI, + MAccountActor, MAccountDefault, - MAccountFormattable, - MAccountId, + MAccountFormattable, MAccountIdActorId, MAccountLight, MAccountSummaryBlocks, MAccountSummaryFormattable, @@ -14,9 +13,9 @@ import { } from '../account/index.js' import { MActor, - MActorAccountChannelId, MActorAPChannel, MActorAPI, + MActorAccountChannelId, MActorDefault, MActorDefaultBanner, MActorDefaultLight, @@ -54,7 +53,7 @@ export type MChannelUserId = export type MChannelAccountIdUrl = Pick<MChannel, 'id' | 'accountId'> & Use<'Actor', MActorUrl & MActorId> & - Use<'Account', MAccountId & MAccountUrl> + Use<'Account', MAccountIdActorId & MAccountUrl> export type MChannelActor = MChannel & diff --git a/server/core/types/models/video/video-comment.ts b/server/core/types/models/video/video-comment.ts index 79c54a1e1..503672177 100644 --- a/server/core/types/models/video/video-comment.ts +++ b/server/core/types/models/video/video-comment.ts @@ -1,13 +1,16 @@ import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils' import { VideoCommentModel } from '../../../models/video/video-comment.js' import { MAccountDefault, MAccountFormattable, MAccountUrl } from '../account/index.js' -import { MVideo, MVideoAccountIdUrl, MVideoAccountLight, MVideoFeed, MVideoIdUrl, MVideoUrl } from './video.js' +import { MCommentAutomaticTagWithTag } from '../automatic-tag/comment-automatic-tag.js' +import { MVideo, MVideoAccountIdUrl, MVideoAccountLight, MVideoFeed, MVideoIdUrl, MVideoImmutable, MVideoUUID } from './video.js' type Use<K extends keyof VideoCommentModel, M> = PickWith<VideoCommentModel, K, M> // ############################################################################ -export type MComment = Omit<VideoCommentModel, 'OriginVideoComment' | 'InReplyToVideoComment' | 'Video' | 'Account'> +export type MComment = + Omit<VideoCommentModel, 'OriginVideoComment' | 'InReplyToVideoComment' | 'Video' | 'Account' | 'CommentAutomaticTags'> + export type MCommentTotalReplies = MComment & { totalReplies?: number } export type MCommentId = Pick<MComment, 'id'> export type MCommentUrl = Pick<MComment, 'url'> @@ -15,8 +18,8 @@ export type MCommentUrl = Pick<MComment, 'url'> // --------------------------------------------------------------------------- export type MCommentExport = - Pick<MComment, 'url' | 'text' | 'createdAt'> & - Use<'Video', MVideoAccountLight> & + Pick<MComment, 'id' | 'url' | 'text' | 'createdAt'> & + Use<'Video', MVideoIdUrl & MVideoUUID> & Use<'InReplyToVideoComment', MCommentUrl> // ############################################################################ @@ -44,11 +47,11 @@ export type MCommentOwnerVideoReply = Use<'Video', MVideoAccountIdUrl> & Use<'InReplyToVideoComment', MComment> -export type MCommentOwnerReplyVideoLight = +export type MCommentOwnerReplyVideoImmutable = MComment & Use<'Account', MAccountDefault> & Use<'InReplyToVideoComment', MComment> & - Use<'Video', MVideoIdUrl> + Use<'Video', MVideoImmutable> export type MCommentOwnerVideoFeed = MCommentOwner & @@ -66,13 +69,14 @@ export type MCommentFormattable = MCommentTotalReplies & Use<'Account', MAccountFormattable> -export type MCommentAdminFormattable = +export type MCommentAdminOrUserFormattable = MComment & Use<'Account', MAccountFormattable> & - Use<'Video', MVideo> + Use<'Video', MVideo> & + Use<'CommentAutomaticTags', MCommentAutomaticTagWithTag[]> export type MCommentAP = MComment & Use<'Account', MAccountUrl> & - PickWithOpt<VideoCommentModel, 'Video', MVideoUrl> & + PickWithOpt<VideoCommentModel, 'Video', MVideoImmutable> & PickWithOpt<VideoCommentModel, 'InReplyToVideoComment', MCommentUrl> diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index 010009af2..cd08d6f24 100644 --- a/server/core/types/models/video/video.ts +++ b/server/core/types/models/video/video.ts @@ -34,7 +34,8 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M> export type MVideo = Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' | 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' | - 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard'> + 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard' | + 'AutomaticTags'> // ############################################################################ diff --git a/server/core/types/models/watched-words/index.ts b/server/core/types/models/watched-words/index.ts new file mode 100644 index 000000000..822e0e3c2 --- /dev/null +++ b/server/core/types/models/watched-words/index.ts @@ -0,0 +1 @@ +export * from './watched-words-list.js' diff --git a/server/core/types/models/watched-words/watched-words-list.ts b/server/core/types/models/watched-words/watched-words-list.ts new file mode 100644 index 000000000..1abac63ba --- /dev/null +++ b/server/core/types/models/watched-words/watched-words-list.ts @@ -0,0 +1,3 @@ +import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js' + +export type MWatchedWordsList = Omit<WatchedWordsListModel, 'Account'> diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index bfdf78bf9..1a20af0d2 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -273,6 +273,10 @@ tags: its root comment thread. - name: Video Blocks description: Operations dealing with blocking videos (removing them from view and preventing interactions). + - name: Automatic Tags + description: Automatic tags set on objects (like comments or videos) by specific rules (external link, watched words, etc.) + - name: Watched Words + description: Manage list of watched words to detect patterns on objects (like comments of videos) - name: Video Rates description: Like/dislike a video. - name: Video Playlists @@ -359,6 +363,8 @@ x-tagGroups: - Video Blocks - Account Blocks - Server Blocks + - Automatic Tags + - Watched Words - name: Instance tags: - Config @@ -467,27 +473,27 @@ paths: - json1 - name: videoId in: query - description: 'limit listing to a specific video' + description: 'limit listing comments to a specific video' schema: type: string - name: accountId in: query - description: 'limit listing to a specific account' + description: 'limit listing comments to videos of a specific account' schema: type: string - name: accountName in: query - description: 'limit listing to a specific account' + description: 'limit listing comments to videos of a specific account' schema: type: string - name: videoChannelId in: query - description: 'limit listing to a specific video channel' + description: 'limit listing comments to videos of a specific video channel' schema: type: string - name: videoChannelName in: query - description: 'limit listing to a specific video channel' + description: 'limit listing comments to videos of a specific video channel' schema: type: string responses: @@ -755,6 +761,7 @@ paths: - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' + - $ref: '#/components/parameters/autoTagOneOfVideo' - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' @@ -1994,6 +2001,38 @@ paths: $ref: '#/components/schemas/UpdateMe' required: true + '/api/v1/users/me/videos/comments': + get: + summary: List comments on user's videos + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + tags: + - Video Comments + parameters: + - $ref: '#/components/parameters/search' + - $ref: '#/components/parameters/searchAccountForComments' + - $ref: '#/components/parameters/searchVideoForComments' + - $ref: '#/components/parameters/videoId' + - $ref: '#/components/parameters/videoChannelId' + - $ref: '#/components/parameters/autoTagOneOfComment' + - $ref: '#/components/parameters/isHeldForReview' + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + properties: + total: + type: integer + example: 1 + data: + type: array + items: + $ref: '#/components/schemas/VideoCommentForOwnerOrAdmin' + /api/v1/users/me/videos/imports: get: summary: Get video imports of my user @@ -2185,6 +2224,7 @@ paths: - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' + - $ref: '#/components/parameters/autoTagOneOfVideo' - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' @@ -2730,6 +2770,7 @@ paths: - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' + - $ref: '#/components/parameters/autoTagOneOfVideo' - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' @@ -2888,8 +2929,11 @@ paths: minLength: 2 maxLength: 30 commentsEnabled: - description: Enable or disable comments for this video + deprecated: true + description: 'Deprecated in 6.2, use commentsPolicy instead' type: boolean + commentsPolicy: + $ref: '#/components/schemas/VideoCommentsPolicySet' downloadEnabled: description: Enable or disable downloading for this video type: boolean @@ -3442,8 +3486,11 @@ paths: minLength: 2 maxLength: 30 commentsEnabled: - description: Enable or disable comments for this live video/replay + deprecated: true + description: 'Deprecated in 6.2, use commentsPolicy instead' type: boolean + commentsPolicy: + $ref: '#/components/schemas/VideoCommentsPolicySet' downloadEnabled: description: Enable or disable downloading for the replay of this live video type: boolean @@ -4389,6 +4436,7 @@ paths: - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' + - $ref: '#/components/parameters/autoTagOneOfVideo' - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' @@ -5168,6 +5216,41 @@ paths: schema: $ref: '#/components/schemas/VideoCommentThreadTree' + '/api/v1/videos/comments': + get: + summary: List instance comments + security: + - OAuth2: + - admin + - moderator + tags: + - Video Comments + parameters: + - $ref: '#/components/parameters/search' + - $ref: '#/components/parameters/searchAccountForComments' + - $ref: '#/components/parameters/searchVideoForComments' + - $ref: '#/components/parameters/videoId' + - $ref: '#/components/parameters/videoChannelId' + - $ref: '#/components/parameters/autoTagOneOfComment' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/onLocalVideo' + + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + properties: + total: + type: integer + example: 1 + data: + type: array + items: + $ref: '#/components/schemas/VideoCommentForOwnerOrAdmin' + '/api/v1/videos/{id}/comments/{commentId}': post: summary: Reply to a thread of a video @@ -5220,6 +5303,21 @@ paths: '409': description: comment is already deleted + '/api/v1/videos/{id}/comments/{commentId}/approve': + post: + summary: Approve a comment + description: "**PeerTube >= 6.2** Approve a comment that requires a review" + security: + - OAuth2: [] + tags: + - Video Comments + parameters: + - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/commentId' + responses: + '204': + description: successful operation + '/api/v1/videos/{id}/rate': put: summary: Like/dislike a video @@ -5341,6 +5439,7 @@ paths: - $ref: '#/components/parameters/tagsAllOf' - $ref: '#/components/parameters/licenceOneOf' - $ref: '#/components/parameters/languageOneOf' + - $ref: '#/components/parameters/autoTagOneOfVideo' - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' @@ -6542,6 +6641,334 @@ paths: items: $ref: '#/components/schemas/RunnerJobAdmin' + /api/v1/automatic-tags/policies/accounts/{accountName}/comments: + get: + tags: + - Automatic Tags + summary: Get account auto tag policies on comments + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + description: account name to get auto tag policies + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/CommentAutoTagPolicies' + put: + tags: + - Automatic Tags + summary: Update account auto tag policies on comments + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + description: account name to update auto tag policies + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + review: + description: 'Auto tags that automatically set the comment in review state' + type: array + items: + type: string + responses: + '204': + description: successful operation + + /api/v1/automatic-tags/accounts/{accountName}/available: + get: + tags: + - Automatic Tags + summary: Get account available auto tags + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + description: account name to get auto tag policies + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AutomaticTagAvailable' + /api/v1/automatic-tags/server/available: + get: + tags: + - Automatic Tags + summary: Get server available auto tags + description: "**PeerTube >= 6.2**" + security: + - OAuth2: + - admin + - moderator + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AutomaticTagAvailable' + + /api/v1/watched-words/accounts/{accountName}/lists: + get: + tags: + - Watched Words + summary: List account watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + description: account name to list watched words + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + properties: + total: + type: integer + example: 1 + data: + type: array + items: + $ref: '#/components/schemas/WatchedWordsLists' + post: + tags: + - Watched Words + summary: Add account watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + listName: + type: string + words: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + properties: + watchedWordsList: + type: object + properties: + id: + type: integer + + /api/v1/watched-words/accounts/{accountName}/lists/{listId}: + put: + tags: + - Watched Words + summary: Update account watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + schema: + type: string + - name: listId + in: path + required: true + description: list of watched words to update + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + listName: + type: string + words: + type: array + items: + type: string + responses: + '204': + description: successful operation + delete: + tags: + - Watched Words + summary: Delete account watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: [] + parameters: + - name: accountName + in: path + required: true + schema: + type: string + - name: listId + in: path + required: true + description: list of watched words to delete + schema: + type: string + responses: + '204': + description: successful operation + + /api/v1/watched-words/server/lists: + get: + tags: + - Watched Words + summary: List server watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: + - admin + - moderator + responses: + '200': + description: successful operation + content: + application/json: + schema: + properties: + total: + type: integer + example: 1 + data: + type: array + items: + $ref: '#/components/schemas/WatchedWordsLists' + post: + tags: + - Watched Words + summary: Add server watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: + - admin + - moderator + requestBody: + content: + application/json: + schema: + type: object + properties: + listName: + type: string + words: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + properties: + watchedWordsList: + type: object + properties: + id: + type: integer + + /api/v1/watched-words/server/lists/{listId}: + put: + tags: + - Watched Words + summary: Update server watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: + - admin + - moderator + parameters: + - name: listId + in: path + required: true + description: list of watched words to update + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + listName: + type: string + words: + type: array + items: + type: string + responses: + '204': + description: successful operation + delete: + tags: + - Watched Words + summary: Delete server watched words + description: "**PeerTube >= 6.2**" + security: + - OAuth2: + - admin + - moderator + parameters: + - name: listId + in: path + required: true + description: list of watched words to delete + schema: + type: string + responses: + '204': + description: successful operation + servers: - url: 'https://peertube2.cpy.re' description: Live Test Server (live data - latest nightly version) @@ -6926,6 +7353,39 @@ components: $ref: '#/components/schemas/VideoLanguageSet' style: form explode: true + autoTagOneOfVideo: + name: autoTagOneOf + in: query + required: false + description: "**PeerTube >= 6.2** **Admins and moderators only** filter on videos that contain one of these automatic tags" + schema: + oneOf: + - type: string + - type: array + items: + type: string + style: form + explode: true + autoTagOneOfComment: + name: autoTagOneOf + in: query + required: false + description: "**PeerTube >= 6.2** filter on comments that contain one of these automatic tags" + schema: + oneOf: + - type: string + - type: array + items: + type: string + style: form + explode: true + isHeldForReview: + name: isHeldForReview + in: query + required: false + description: only display comments that are held for review + schema: + type: boolean licenceOneOf: name: licenceOneOf in: query @@ -6966,7 +7426,7 @@ components: required: false schema: type: boolean - description: '**PeerTube >= 4.0** Display only local or remote videos' + description: '**PeerTube >= 4.0** Display only local or remote objects' hasHLSFiles: name: hasHLSFiles in: query @@ -7157,6 +7617,41 @@ components: description: The video password id schema: $ref: '#/components/schemas/id' + onLocalVideo: + name: onLocalVideo + in: query + required: false + schema: + type: boolean + description: 'Display only objects of local or remote videos' + videoChannelId: + name: videoChannelId + in: query + required: false + schema: + type: integer + description: 'Limit results on this specific video channel' + videoId: + name: videoId + in: query + required: false + schema: + type: integer + description: 'Limit results on this specific video' + searchVideoForComments: + name: searchVideo + in: query + required: false + description: Filter comments by searching on the video + schema: + type: string + searchAccountForComments: + name: searchAccount + in: query + required: false + description: Filter comments by searching on the account + schema: + type: string videoPasswordHeader: name: x-peertube-video-password description: Required on password protected video @@ -7353,6 +7848,20 @@ components: label: type: string + VideoCommentsPolicySet: + type: integer + enum: + - 1 + - 2 + - 3 + description: Comments policy of the video (Enabled = `1`, Disabled = `2`, Requires Approval = `3`) + VideoCommentsPolicyConstant: + properties: + id: + $ref: '#/components/schemas/VideoCommentsPolicySet' + label: + type: string + BlockStatus: properties: accounts: @@ -7822,7 +8331,11 @@ components: minLength: 2 maxLength: 30 commentsEnabled: + deprecated: true + description: 'Deprecated in 6.2, use commentsPolicy instead' type: boolean + commentsPolicy: + $ref: '#/components/schemas/VideoCommentsPolicyConstant' downloadEnabled: type: boolean inputFileUpdatedAt: @@ -7903,6 +8416,30 @@ components: type: array items: $ref: '#/components/schemas/FileRedundancyInformation' + CommentAutoTagPolicies: + properties: + review: + type: array + description: 'Auto tags that automatically set the comment in review state' + items: + type: string + AutomaticTagAvailable: + properties: + available: + type: array + description: 'Available auto tags that can be used to filter objects or set a comment in review state' + items: + type: object + properties: + name: + type: string + description: tag name + type: + type: string + enum: + - 'core' + - 'watched-words-list' + VideoImportStateConstant: properties: id: @@ -8135,6 +8672,8 @@ components: isDeleted: type: boolean default: false + heldForReview: + type: boolean totalRepliesFromVideoAuthor: type: integer minimum: 0 @@ -8151,6 +8690,33 @@ components: type: array items: $ref: '#/components/schemas/VideoCommentThreadTree' + VideoCommentForOwnerOrAdmin: + properties: + id: + $ref: '#/components/schemas/id' + url: + $ref: '#/components/schemas/VideoComment/properties/url' + text: + $ref: '#/components/schemas/VideoComment/properties/text' + heldForReview: + $ref: '#/components/schemas/VideoComment/properties/heldForReview' + threadId: + $ref: '#/components/schemas/VideoComment/properties/threadId' + inReplyToCommentId: + $ref: '#/components/schemas/VideoComment/properties/inReplyToCommentId' + createdAt: + $ref: '#/components/schemas/VideoComment/properties/createdAt' + updatedAt: + $ref: '#/components/schemas/VideoComment/properties/updatedAt' + account: + $ref: '#/components/schemas/VideoComment/properties/account' + video: + $ref: '#/components/schemas/VideoInfo' + automaticTags: + type: array + items: + type: string + Storyboard: properties: storyboardPath: @@ -9118,8 +9684,11 @@ components: minLength: 2 maxLength: 30 commentsEnabled: - description: Enable or disable comments for this video + deprecated: true + description: 'Deprecated in 6.2, use commentsPolicy instead' type: boolean + commentsPolicy: + $ref: '#/components/schemas/VideoCommentsPolicySet' downloadEnabled: description: Enable or disable downloading for this video type: boolean @@ -9997,6 +10566,12 @@ components: - `17` NEW_PLUGIN_VERSION - `18` NEW_PEERTUBE_VERSION + + - `19` MY_VIDEO_STUDIO_EDITION_FINISHED + + - `20` NEW_USER_REGISTRATION_REQUEST + + - `21` NEW_LIVE_FROM_SUBSCRIPTION read: type: boolean video: @@ -10037,6 +10612,8 @@ components: $ref: '#/components/schemas/VideoInfo' account: $ref: '#/components/schemas/ActorInfo' + heldForReview: + type: boolean videoAbuse: nullable: true type: object @@ -10546,6 +11123,25 @@ components: privatePayload: type: object + WatchedWordsLists: + properties: + id: + $ref: '#/components/schemas/id' + listName: + type: string + words: + type: array + items: + type: string + updatedAt: + type: string + format: date-time + example: 2021-05-04T08:01:01.502Z + createdAt: + type: string + format: date-time + example: 2021-05-04T08:01:01.502Z + VideoPassword: properties: id: diff --git a/yarn.lock b/yarn.lock index 220d4cd1b..22a34b4b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2557,6 +2557,11 @@ dependencies: "@types/node" "*" +"@types/linkify-it@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.5.tgz#1e78a3ac2428e6d7e6c05c1665c242023a4601d8" + integrity sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw== + "@types/lodash-es@^4.17.8": version "4.17.12" resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"