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 f80de7acd..6a0e8dade 100644 --- a/client/src/app/+admin/overview/videos/video-admin.service.ts +++ b/client/src/app/+admin/overview/videos/video-admin.service.ts @@ -5,7 +5,8 @@ import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { CommonVideoParams, Video, VideoService } from '@app/shared/shared-main' -import { ResultList, VideoInclude } from '@shared/models' +import { ResultList, VideoInclude, VideoPrivacy } from '@shared/models' +import { getAllPrivacies } from '@shared/core-utils' @Injectable() export class VideoAdminService { @@ -96,6 +97,10 @@ export class VideoAdminService { { value: 'excludeMuted', label: $localize`Exclude muted accounts` + }, + { + value: 'excludePublic', + label: $localize`Exclude public videos` } ] } @@ -105,11 +110,12 @@ export class VideoAdminService { private buildAdminParamsFromSearch (search: string, params: HttpParams) { let include = VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER | - VideoInclude.HIDDEN_PRIVACY | VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.FILES - if (!search) return this.restService.addObjectParams(params, { include }) + let privacyOneOf = getAllPrivacies() + + if (!search) return this.restService.addObjectParams(params, { include, privacyOneOf }) const filters = this.restService.parseQueryStringFilter(search, { isLocal: { @@ -131,6 +137,10 @@ export class VideoAdminService { excludeMuted: { prefix: 'excludeMuted', handler: () => true + }, + excludePublic: { + prefix: 'excludePublic', + handler: () => true } }) @@ -140,6 +150,12 @@ export class VideoAdminService { filters.excludeMuted = undefined } - return this.restService.addObjectParams(params, { ...filters, include }) + if (filters.excludePublic) { + privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] + + filters.excludePublic = undefined + } + + return this.restService.addObjectParams(params, { ...filters, include, privacyOneOf }) } } 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 6edcc3fe0..570e8e3be 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -38,6 +38,7 @@ export type CommonVideoParams = { isLocal?: boolean categoryOneOf?: number[] languageOneOf?: string[] + privacyOneOf?: VideoPrivacy[] isLive?: boolean skipCount?: boolean @@ -392,6 +393,7 @@ export class VideoService { include, categoryOneOf, languageOneOf, + privacyOneOf, skipCount, nsfwPolicy, isLive, @@ -413,6 +415,7 @@ export class VideoService { if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf) if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf) + if (privacyOneOf) newParams = this.restService.addArrayParams(newParams, 'privacyOneOf', privacyOneOf) return newParams } diff --git a/client/src/app/shared/shared-video-miniature/video-filters.model.ts b/client/src/app/shared/shared-video-miniature/video-filters.model.ts index 5ad7cf3f7..982880b0e 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters.model.ts +++ b/client/src/app/shared/shared-video-miniature/video-filters.model.ts @@ -1,6 +1,6 @@ import { intoArray, toBoolean } from '@app/helpers' -import { AttributesOnly } from '@shared/core-utils' -import { BooleanBothQuery, NSFWPolicyType, VideoInclude, VideoSortField } from '@shared/models' +import { AttributesOnly, getAllPrivacies } from '@shared/core-utils' +import { BooleanBothQuery, NSFWPolicyType, VideoInclude, VideoPrivacy, VideoSortField } from '@shared/models' type VideoFiltersKeys = { [ id in keyof AttributesOnly ]: any @@ -198,13 +198,15 @@ export class VideoFilters { toVideosAPIObject () { let isLocal: boolean let include: VideoInclude + let privacyOneOf: VideoPrivacy[] if (this.scope === 'local') { isLocal = true } if (this.allVideos) { - include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.HIDDEN_PRIVACY + include = VideoInclude.NOT_PUBLISHED_STATE + privacyOneOf = getAllPrivacies() } let isLive: boolean @@ -219,6 +221,7 @@ export class VideoFilters { search: this.search, isLocal, include, + privacyOneOf, isLive } } diff --git a/server/controllers/client.ts b/server/controllers/client.ts index 0a27ace76..703166c01 100644 --- a/server/controllers/client.ts +++ b/server/controllers/client.ts @@ -10,7 +10,7 @@ import { HttpStatusCode } from '@shared/models' import { root } from '../helpers/core-utils' import { STATIC_MAX_AGE } from '../initializers/constants' import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/client-html' -import { asyncMiddleware, disableRobots, embedCSP } from '../middlewares' +import { asyncMiddleware, embedCSP } from '../middlewares' const clientsRouter = express.Router() diff --git a/server/helpers/query.ts b/server/helpers/query.ts index 97bbdfc65..1142d02e4 100644 --- a/server/helpers/query.ts +++ b/server/helpers/query.ts @@ -16,6 +16,7 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) { 'categoryOneOf', 'licenceOneOf', 'languageOneOf', + 'privacyOneOf', 'tagsOneOf', 'tagsAllOf', 'isLocal', diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 53643635c..4916decbf 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -7,6 +7,7 @@ import { isAbleToUploadVideo } from '@server/lib/user' import { getServerActor } from '@server/models/application/application' import { ExpressPromiseHandler } from '@server/types/express' import { MUserAccountId, MVideoFullLight } from '@server/types/models' +import { getAllPrivacies } from '@shared/core-utils' import { VideoInclude } from '@shared/models' import { ServerErrorCode, UserRight, VideoPrivacy } from '../../../../shared' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' @@ -487,6 +488,10 @@ const commonVideosFiltersValidator = [ .optional() .customSanitizer(toArray) .custom(isStringArray).withMessage('Should have a valid one of language array'), + query('privacyOneOf') + .optional() + .customSanitizer(toArray) + .custom(isNumberArray).withMessage('Should have a valid one of privacy array'), query('tagsOneOf') .optional() .customSanitizer(toArray) @@ -536,10 +541,12 @@ const commonVideosFiltersValidator = [ // FIXME: deprecated in 4.0, to remove { if (req.query.filter === 'all-local') { - req.query.include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.HIDDEN_PRIVACY + req.query.include = VideoInclude.NOT_PUBLISHED_STATE req.query.isLocal = true + req.query.privacyOneOf = getAllPrivacies() } else if (req.query.filter === 'all') { - req.query.include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.HIDDEN_PRIVACY + req.query.include = VideoInclude.NOT_PUBLISHED_STATE + req.query.privacyOneOf = getAllPrivacies() } else if (req.query.filter === 'local') { req.query.isLocal = true } @@ -550,7 +557,7 @@ const commonVideosFiltersValidator = [ const user = res.locals.oauth?.token.User if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) { - if (req.query.include) { + if (req.query.include || req.query.privacyOneOf) { return res.fail({ status: HttpStatusCode.UNAUTHORIZED_401, message: 'You are not allowed to see all videos.' diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/videos-id-list-query-builder.ts index 4a882e790..d825225ab 100644 --- a/server/models/video/sql/videos-id-list-query-builder.ts +++ b/server/models/video/sql/videos-id-list-query-builder.ts @@ -40,6 +40,7 @@ export type BuildVideosListQueryOptions = { languageOneOf?: string[] tagsOneOf?: string[] tagsAllOf?: string[] + privacyOneOf?: VideoPrivacy[] uuids?: string[] @@ -138,11 +139,6 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { this.whereStateAvailable() } - // Only list videos with the appropriate priavcy - if (!(options.include & VideoInclude.HIDDEN_PRIVACY)) { - this.wherePrivacyAvailable(options.user) - } - if (options.videoPlaylistId) { this.joinPlaylist(options.videoPlaylistId) } @@ -187,6 +183,13 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { this.whereTagsAllOf(options.tagsAllOf) } + if (options.privacyOneOf) { + this.wherePrivacyOneOf(options.privacyOneOf) + } else { + // Only list videos with the appropriate priavcy + this.wherePrivacyAvailable(options.user) + } + if (options.uuids) { this.whereUUIDs(options.uuids) } @@ -435,6 +438,11 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { ) } + private wherePrivacyOneOf (privacyOneOf: VideoPrivacy[]) { + this.and.push('"video"."privacy" IN (:privacyOneOf)') + this.replacements.privacyOneOf = privacyOneOf + } + private whereUUIDs (uuids: string[]) { this.and.push('"video"."uuid" IN (' + createSafeIn(this.sequelize, uuids) + ')') } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 003741da0..69d009e04 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1041,6 +1041,7 @@ export class VideoModel extends Model>> { languageOneOf?: string[] tagsOneOf?: string[] tagsAllOf?: string[] + privacyOneOf?: VideoPrivacy[] accountId?: number videoChannelId?: number @@ -1059,6 +1060,7 @@ export class VideoModel extends Model>> { search?: string }) { VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) + VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) const trendingDays = options.sort.endsWith('trending') ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS @@ -1082,6 +1084,7 @@ export class VideoModel extends Model>> { 'languageOneOf', 'tagsOneOf', 'tagsAllOf', + 'privacyOneOf', 'isLocal', 'include', 'displayOnlyForFollower', @@ -1119,6 +1122,7 @@ export class VideoModel extends Model>> { languageOneOf?: string[] tagsOneOf?: string[] tagsAllOf?: string[] + privacyOneOf?: VideoPrivacy[] displayOnlyForFollower: DisplayOnlyForFollowerOptions | null @@ -1140,6 +1144,7 @@ export class VideoModel extends Model>> { uuids?: string[] }) { VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) + VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) const serverActor = await getServerActor() @@ -1153,6 +1158,7 @@ export class VideoModel extends Model>> { 'languageOneOf', 'tagsOneOf', 'tagsAllOf', + 'privacyOneOf', 'user', 'isLocal', 'host', @@ -1510,14 +1516,19 @@ export class VideoModel extends Model>> { private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) { if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) { - throw new Error('Try to filter all-local but no user has not the see all videos right') + throw new Error('Try to filter all-local but user cannot see all videos') + } + } + + private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) { + if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) { + throw new Error('Try to choose video privacies but user cannot see all videos') } } private static isPrivateInclude (include: VideoInclude) { return include & VideoInclude.BLACKLISTED || include & VideoInclude.BLOCKED_OWNER || - include & VideoInclude.HIDDEN_PRIVACY || include & VideoInclude.NOT_PUBLISHED_STATE } diff --git a/server/tests/api/check-params/videos-common-filters.ts b/server/tests/api/check-params/videos-common-filters.ts index afe42b0d5..f2b5bee8e 100644 --- a/server/tests/api/check-params/videos-common-filters.ts +++ b/server/tests/api/check-params/videos-common-filters.ts @@ -9,7 +9,7 @@ import { setAccessTokensToServers, setDefaultVideoChannel } from '@shared/extra-utils' -import { HttpStatusCode, UserRole, VideoInclude } from '@shared/models' +import { HttpStatusCode, UserRole, VideoInclude, VideoPrivacy } from '@shared/models' describe('Test video filters validators', function () { let server: PeerTubeServer @@ -112,7 +112,7 @@ describe('Test video filters validators', function () { const validIncludes = [ VideoInclude.NONE, - VideoInclude.HIDDEN_PRIVACY, + VideoInclude.BLOCKED_OWNER, VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED ] @@ -120,6 +120,7 @@ describe('Test video filters validators', function () { token?: string isLocal?: boolean include?: VideoInclude + privacyOneOf?: VideoPrivacy[] expectedStatus: HttpStatusCode }) { const paths = [ @@ -136,6 +137,7 @@ describe('Test video filters validators', function () { token: options.token || server.accessToken, query: { isLocal: options.isLocal, + privacyOneOf: options.privacyOneOf, include: options.include }, expectedStatus: options.expectedStatus @@ -143,6 +145,22 @@ describe('Test video filters validators', function () { } } + it('Should fail with a bad privacyOneOf', async function () { + await testEndpoints({ privacyOneOf: [ 'toto' ] as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a good privacyOneOf', async function () { + await testEndpoints({ privacyOneOf: [ VideoPrivacy.INTERNAL ], expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should fail to use privacyOneOf with a simple user', async function () { + await testEndpoints({ + privacyOneOf: [ VideoPrivacy.INTERNAL ], + token: userAccessToken, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + it('Should fail with a bad include', async function () { await testEndpoints({ include: 'toto' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) diff --git a/server/tests/api/videos/videos-common-filters.ts b/server/tests/api/videos/videos-common-filters.ts index 4f22d4ac3..ca5f42173 100644 --- a/server/tests/api/videos/videos-common-filters.ts +++ b/server/tests/api/videos/videos-common-filters.ts @@ -138,6 +138,7 @@ describe('Test videos filter', function () { hasWebtorrentFiles?: boolean hasHLSFiles?: boolean include?: VideoInclude + privacyOneOf?: VideoPrivacy[] category?: number tagsAllOf?: string[] token?: string @@ -148,7 +149,7 @@ describe('Test videos filter', function () { path: options.path, token: options.token ?? options.server.accessToken, query: { - ...pick(options, [ 'isLocal', 'include', 'category', 'tagsAllOf', 'hasWebtorrentFiles', 'hasHLSFiles' ]), + ...pick(options, [ 'isLocal', 'include', 'category', 'tagsAllOf', 'hasWebtorrentFiles', 'hasHLSFiles', 'privacyOneOf' ]), sort: 'createdAt' }, @@ -162,6 +163,7 @@ describe('Test videos filter', function () { server: PeerTubeServer isLocal?: boolean include?: VideoInclude + privacyOneOf?: VideoPrivacy[] token?: string expectedStatus?: HttpStatusCode }) { @@ -195,7 +197,7 @@ describe('Test videos filter', function () { server, token, isLocal: true, - include: VideoInclude.HIDDEN_PRIVACY + privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ] }) for (const names of namesResults) { @@ -216,7 +218,7 @@ describe('Test videos filter', function () { const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames({ server, token, - include: VideoInclude.HIDDEN_PRIVACY + privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ] }) expect(channelVideos).to.have.lengthOf(3) diff --git a/shared/core-utils/videos/index.ts b/shared/core-utils/videos/index.ts index 5a1145f1a..620e3a716 100644 --- a/shared/core-utils/videos/index.ts +++ b/shared/core-utils/videos/index.ts @@ -1 +1,2 @@ export * from './bitrate' +export * from './privacy' diff --git a/shared/core-utils/videos/privacy.ts b/shared/core-utils/videos/privacy.ts new file mode 100644 index 000000000..7d3b67d50 --- /dev/null +++ b/shared/core-utils/videos/privacy.ts @@ -0,0 +1,9 @@ +import { VideoPrivacy } from '../../models/videos/video-privacy.enum' + +function getAllPrivacies () { + return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED ] +} + +export { + getAllPrivacies +} diff --git a/shared/models/search/videos-common-query.model.ts b/shared/models/search/videos-common-query.model.ts index e9edb91b0..2cbf7b014 100644 --- a/shared/models/search/videos-common-query.model.ts +++ b/shared/models/search/videos-common-query.model.ts @@ -1,3 +1,4 @@ +import { VideoPrivacy } from '@shared/models' import { VideoInclude } from '../videos/video-include.enum' import { BooleanBothQuery } from './boolean-both-query.model' @@ -23,6 +24,8 @@ export interface VideosCommonQuery { languageOneOf?: string[] + privacyOneOf?: VideoPrivacy[] + tagsOneOf?: string[] tagsAllOf?: string[] diff --git a/shared/models/videos/video-include.enum.ts b/shared/models/videos/video-include.enum.ts index 72fa8cd30..7e16b129a 100644 --- a/shared/models/videos/video-include.enum.ts +++ b/shared/models/videos/video-include.enum.ts @@ -1,8 +1,7 @@ export const enum VideoInclude { NONE = 0, NOT_PUBLISHED_STATE = 1 << 0, - HIDDEN_PRIVACY = 1 << 1, - BLACKLISTED = 1 << 2, - BLOCKED_OWNER = 1 << 3, - FILES = 1 << 4 + BLACKLISTED = 1 << 1, + BLOCKED_OWNER = 1 << 2, + FILES = 1 << 3 } diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 13757152c..88a089fc7 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -369,6 +369,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' - $ref: '#/components/parameters/skipCount' @@ -1305,6 +1306,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' - $ref: '#/components/parameters/skipCount' @@ -1628,6 +1630,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' - $ref: '#/components/parameters/skipCount' @@ -2867,6 +2870,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' - $ref: '#/components/parameters/skipCount' @@ -3590,6 +3594,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' - $ref: '#/components/parameters/skipCount' @@ -4095,6 +4100,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' responses: @@ -4179,6 +4185,7 @@ paths: - $ref: '#/components/parameters/nsfw' - $ref: '#/components/parameters/isLocal' - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' - $ref: '#/components/parameters/hasHLSFiles' - $ref: '#/components/parameters/hasWebtorrentFiles' responses: @@ -4834,6 +4841,13 @@ components: schema: type: boolean description: '**PeerTube >= 4.0** Display only videos that have WebTorrent files' + privacyOneOf: + name: privacyOneOf + in: query + required: false + schema: + $ref: '#/components/schemas/VideoPrivacySet' + description: '**PeerTube >= 4.0** Display only videos in this specific privacy/privacies' include: name: include in: query @@ -4853,11 +4867,11 @@ components: - `1` NOT_PUBLISHED_STATE - - `2` HIDDEN_PRIVACY + - `2` BLACKLISTED - - `4` BLACKLISTED + - `4` BLOCKED_OWNER - - `8` BLOCKED + - `8` FILES subscriptionsUris: name: uris in: query