diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss index 2fbfa335b..8cb0b677d 100644 --- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss +++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss @@ -37,13 +37,6 @@ .actor-owner { @include actor-owner; } - - my-subscribe-button { - /deep/ span[role=button] { - padding: 7px 12px; - font-size: 16px; - } - } } diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts index 1e94cf90b..9434b196f 100644 --- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts +++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts @@ -21,7 +21,7 @@ export class MyAccountSubscriptionsComponent implements OnInit { ngOnInit () { this.userSubscriptionService.listSubscriptions() .subscribe( - res => { console.log(res); this.videoChannels = res.data }, + res => this.videoChannels = res.data, error => this.notificationsService.error(this.i18n('Error'), error.message) ) diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss index 5c892be01..83d657f03 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss @@ -41,6 +41,10 @@ color: $grey-actor-name; margin-left: 5px; } + + .video-channel-followers { + + } } } diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html index bbc70f772..128cc52f5 100644 --- a/client/src/app/search/search.component.html +++ b/client/src/app/search/search.component.html @@ -22,10 +22,27 @@ -
+
No results found
+
+ + Avatar + + +
+ +
{{ videoChannel.displayName }}
+
{{ videoChannel.name }}
+
+ +
{{ videoChannel.followersCount }} subscribers
+
+ + +
+
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss index e54a8b32a..be7dd39cf 100644 --- a/client/src/app/search/search.component.scss +++ b/client/src/app/search/search.component.scss @@ -103,6 +103,42 @@ } } } + + &.video-channel { + + img { + @include avatar(120px); + + margin: 0 50px 0 40px; + } + + .video-channel-info { + + + flex-grow: 1; + width: fit-content; + + .video-channel-names { + @include disable-default-a-behaviour; + + display: flex; + align-items: baseline; + color: #000; + width: fit-content; + + .video-channel-display-name { + font-weight: $font-semibold; + font-size: 18px; + } + + .video-channel-name { + font-size: 14px; + color: $grey-actor-name; + margin-left: 5px; + } + } + } + } } } diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts index 8d615fd05..f88df6391 100644 --- a/client/src/app/search/search.component.ts +++ b/client/src/app/search/search.component.ts @@ -2,13 +2,15 @@ import { Component, OnDestroy, OnInit } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { RedirectService } from '@app/core' import { NotificationsService } from 'angular2-notifications' -import { Subscription } from 'rxjs' +import { forkJoin, Subscription } from 'rxjs' import { SearchService } from '@app/search/search.service' import { ComponentPagination } from '@app/shared/rest/component-pagination.model' import { I18n } from '@ngx-translate/i18n-polyfill' import { Video } from '../../../../shared' import { MetaService } from '@ngx-meta/core' import { AdvancedSearch } from '@app/search/advanced-search.model' +import { VideoChannel } from '@app/shared/video-channel/video-channel.model' +import { immutableAssign } from '@app/shared/misc/utils' @Component({ selector: 'my-search', @@ -17,18 +19,22 @@ import { AdvancedSearch } from '@app/search/advanced-search.model' }) export class SearchComponent implements OnInit, OnDestroy { videos: Video[] = [] + videoChannels: VideoChannel[] = [] + pagination: ComponentPagination = { currentPage: 1, - itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc) + itemsPerPage: 10, // Only for videos, use another variable for channels totalItems: null } advancedSearch: AdvancedSearch = new AdvancedSearch() isSearchFilterCollapsed = true + currentSearch: string private subActivatedRoute: Subscription - private currentSearch: string private isInitialLoad = true + private channelsPerPage = 2 + constructor ( private i18n: I18n, private route: ActivatedRoute, @@ -74,17 +80,23 @@ export class SearchComponent implements OnInit, OnDestroy { } search () { - return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch) + forkJoin([ + this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch), + this.searchService.searchVideoChannels(this.currentSearch, immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage })) + ]) .subscribe( - ({ videos, totalVideos }) => { - this.videos = this.videos.concat(videos) - this.pagination.totalItems = totalVideos + ([ videosResult, videoChannelsResult ]) => { + this.videos = this.videos.concat(videosResult.videos) + this.pagination.totalItems = videosResult.totalVideos + + this.videoChannels = videoChannelsResult.data }, error => { this.notificationsService.error(this.i18n('Error'), error.message) } ) + } onNearOfBottom () { diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts index a37c49161..cd3bdad35 100644 --- a/client/src/app/search/search.service.ts +++ b/client/src/app/search/search.service.ts @@ -1,4 +1,4 @@ -import { catchError, switchMap } from 'rxjs/operators' +import { catchError, map, switchMap } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { Observable } from 'rxjs' @@ -6,13 +6,11 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model import { VideoService } from '@app/shared/video/video.service' import { RestExtractor, RestService } from '@app/shared' import { environment } from 'environments/environment' -import { ResultList, Video } from '../../../../shared' -import { Video as VideoServerModel } from '@app/shared/video/video.model' +import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared' +import { Video } from '@app/shared/video/video.model' import { AdvancedSearch } from '@app/search/advanced-search.model' - -export type SearchResult = { - videosResult: { totalVideos: number, videos: Video[] } -} +import { VideoChannel } from '@app/shared/video-channel/video-channel.model' +import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' @Injectable() export class SearchService { @@ -40,17 +38,7 @@ export class SearchService { if (search) params = params.append('search', search) const advancedSearchObject = advancedSearch.toAPIObject() - - for (const name of Object.keys(advancedSearchObject)) { - const value = advancedSearchObject[name] - if (!value) continue - - if (Array.isArray(value) && value.length !== 0) { - for (const v of value) params = params.append(name, v) - } else { - params = params.append(name, value) - } - } + params = this.restService.addObjectParams(params, advancedSearchObject) return this.authHttp .get>(url, { params }) @@ -59,4 +47,24 @@ export class SearchService { catchError(err => this.restExtractor.handleError(err)) ) } + + searchVideoChannels ( + search: string, + componentPagination: ComponentPagination + ): Observable<{ data: VideoChannel[], total: number }> { + const url = SearchService.BASE_SEARCH_URL + 'video-channels' + + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + params = params.append('search', search) + + return this.authHttp + .get>(url, { params }) + .pipe( + map(res => VideoChannelService.extractVideoChannels(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } } diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts index 5d5410de9..4560c2024 100644 --- a/client/src/app/shared/rest/rest.service.ts +++ b/client/src/app/shared/rest/rest.service.ts @@ -32,6 +32,21 @@ export class RestService { return newParams } + addObjectParams (params: HttpParams, object: object) { + for (const name of Object.keys(object)) { + const value = object[name] + if (!value) continue + + if (Array.isArray(value) && value.length !== 0) { + for (const v of value) params = params.append(name, v) + } else { + params = params.append(name, value) + } + } + + return params + } + componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination { const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage const count: number = componentPagination.itemsPerPage diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html index 63b313662..34c024c17 100644 --- a/client/src/app/shared/user-subscription/subscribe-button.component.html +++ b/client/src/app/shared/user-subscription/subscribe-button.component.html @@ -1,11 +1,11 @@ -
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss index 5bf2f485a..6b18dc88a 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ b/client/src/app/videos/+video-watch/video-watch.component.scss @@ -127,10 +127,6 @@ } my-subscribe-button { - /deep/ span[role=button] { - font-size: 13px !important; - } - margin-left: 5px; } } diff --git a/config/production.yaml.example b/config/production.yaml.example index 272a3cb46..fc698ae96 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -2,7 +2,7 @@ listen: hostname: 'localhost' port: 9000 -# Correspond to your reverse proxy "listen" configuration +# Correspond to your reverse proxy server_name/listen configuration webserver: https: true hostname: 'example.com' diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index f408e7932..87aa5d76f 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts @@ -1,22 +1,26 @@ import * as express from 'express' import { buildNSFWFilter } from '../../helpers/express-utils' -import { getFormattedObjects } from '../../helpers/utils' +import { getFormattedObjects, getServerActor } from '../../helpers/utils' import { VideoModel } from '../../models/video/video' import { asyncMiddleware, commonVideosFiltersValidator, optionalAuthenticate, paginationValidator, - searchValidator, setDefaultPagination, setDefaultSearchSort, - videosSearchSortValidator + videoChannelsSearchSortValidator, + videoChannelsSearchValidator, + videosSearchSortValidator, + videosSearchValidator } from '../../middlewares' -import { VideosSearchQuery } from '../../../shared/models/search' -import { getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub' +import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' +import { getOrCreateActorAndServerAndModel, getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub' import { logger } from '../../helpers/logger' import { User } from '../../../shared/models/users' import { CONFIG } from '../../initializers/constants' +import { VideoChannelModel } from '../../models/video/video-channel' +import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger' const searchRouter = express.Router() @@ -27,21 +31,80 @@ searchRouter.get('/videos', setDefaultSearchSort, optionalAuthenticate, commonVideosFiltersValidator, - searchValidator, + videosSearchValidator, asyncMiddleware(searchVideos) ) +searchRouter.get('/video-channels', + paginationValidator, + setDefaultPagination, + videoChannelsSearchSortValidator, + setDefaultSearchSort, + optionalAuthenticate, + commonVideosFiltersValidator, + videoChannelsSearchValidator, + asyncMiddleware(searchVideoChannels) +) + // --------------------------------------------------------------------------- export { searchRouter } // --------------------------------------------------------------------------- +function searchVideoChannels (req: express.Request, res: express.Response) { + const query: VideoChannelsSearchQuery = req.query + const search = query.search + + const isURISearch = search.startsWith('http://') || search.startsWith('https://') + + const parts = search.split('@') + const isHandleSearch = parts.length === 2 && parts.every(p => p.indexOf(' ') === -1) + + if (isURISearch || isHandleSearch) return searchVideoChannelURI(search, isHandleSearch, res) + + return searchVideoChannelsDB(query, res) +} + +async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { + const serverActor = await getServerActor() + + const options = { + actorId: serverActor.id, + search: query.search, + start: query.start, + count: query.count, + sort: query.sort + } + const resultList = await VideoChannelModel.searchForApi(options) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function searchVideoChannelURI (search: string, isHandleSearch: boolean, res: express.Response) { + let videoChannel: VideoChannelModel + + if (isUserAbleToSearchRemoteURI(res)) { + let uri = search + if (isHandleSearch) uri = await loadActorUrlOrGetFromWebfinger(search) + + const actor = await getOrCreateActorAndServerAndModel(uri) + videoChannel = actor.VideoChannel + } else { + videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(search) + } + + return res.json({ + total: videoChannel ? 1 : 0, + data: videoChannel ? [ videoChannel.toFormattedJSON() ] : [] + }) +} + function searchVideos (req: express.Request, res: express.Response) { const query: VideosSearchQuery = req.query const search = query.search if (search && (search.startsWith('http://') || search.startsWith('https://'))) { - return searchVideoUrl(search, res) + return searchVideoURI(search, res) } return searchVideosDB(query, res) @@ -57,15 +120,11 @@ async function searchVideosDB (query: VideosSearchQuery, res: express.Response) return res.json(getFormattedObjects(resultList.data, resultList.total)) } -async function searchVideoUrl (url: string, res: express.Response) { +async function searchVideoURI (url: string, res: express.Response) { let video: VideoModel - const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined // Check if we can fetch a remote video with the URL - if ( - CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true || - (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined) - ) { + if (isUserAbleToSearchRemoteURI(res)) { try { const syncParam = { likes: false, @@ -76,8 +135,8 @@ async function searchVideoUrl (url: string, res: express.Response) { refreshVideo: false } - const res = await getOrCreateVideoAndAccountAndChannel(url, syncParam) - video = res ? res.video : undefined + const result = await getOrCreateVideoAndAccountAndChannel(url, syncParam) + video = result ? result.video : undefined } catch (err) { logger.info('Cannot search remote video %s.', url) } @@ -90,3 +149,10 @@ async function searchVideoUrl (url: string, res: express.Response) { data: video ? [ video.toFormattedJSON() ] : [] }) } + +function isUserAbleToSearchRemoteURI (res: express.Response) { + const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined + + return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true || + (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined) +} diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 2300f5dbe..000c706b5 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -20,7 +20,8 @@ import { deleteMeValidator, userSubscriptionsSortValidator, videoImportsSortValidator, - videosSortValidator + videosSortValidator, + areSubscriptionsExistValidator } from '../../../middlewares/validators' import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { UserModel } from '../../../models/account/user' @@ -98,7 +99,6 @@ meRouter.post('/me/avatar/pick', // ##### Subscriptions part ##### meRouter.get('/me/subscriptions/videos', - authenticate, authenticate, paginationValidator, videosSortValidator, @@ -108,6 +108,12 @@ meRouter.get('/me/subscriptions/videos', asyncMiddleware(getUserSubscriptionVideos) ) +meRouter.get('/me/subscriptions/exist', + authenticate, + areSubscriptionsExistValidator, + asyncMiddleware(areSubscriptionsExist) +) + meRouter.get('/me/subscriptions', authenticate, paginationValidator, @@ -143,6 +149,37 @@ export { // --------------------------------------------------------------------------- +async function areSubscriptionsExist (req: express.Request, res: express.Response) { + const uris = req.query.uris as string[] + const user = res.locals.oauth.token.User as UserModel + + const handles = uris.map(u => { + let [ name, host ] = u.split('@') + if (host === CONFIG.WEBSERVER.HOST) host = null + + return { name, host, uri: u } + }) + + const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles) + + const existObject: { [id: string ]: boolean } = {} + for (const handle of handles) { + const obj = results.find(r => { + const server = r.ActorFollowing.Server + + return r.ActorFollowing.preferredUsername === handle.name && + ( + (!server && !handle.host) || + (server.host === handle.host) + ) + }) + + existObject[handle.uri] = obj !== undefined + } + + return res.json(existObject) +} + async function addUserSubscription (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User as UserModel const [ name, host ] = req.body.uri.split('@') diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 3f51f03f4..bd08d7a08 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -1,5 +1,5 @@ import * as express from 'express' -import { getFormattedObjects } from '../../helpers/utils' +import { getFormattedObjects, getServerActor } from '../../helpers/utils' import { asyncMiddleware, asyncRetryTransactionMiddleware, @@ -95,7 +95,8 @@ export { // --------------------------------------------------------------------------- async function listVideoChannels (req: express.Request, res: express.Response, next: express.NextFunction) { - const resultList = await VideoChannelModel.listForApi(req.query.start, req.query.count, req.query.sort) + const serverActor = await getServerActor() + const resultList = await VideoChannelModel.listForApi(serverActor.id, req.query.start, req.query.count, req.query.sort) return res.json(getFormattedObjects(resultList.data, resultList.total)) } diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts index c3a62c12d..6958b2b00 100644 --- a/server/helpers/custom-validators/activitypub/actor.ts +++ b/server/helpers/custom-validators/activitypub/actor.ts @@ -1,6 +1,6 @@ import * as validator from 'validator' import { CONSTRAINTS_FIELDS } from '../../../initializers' -import { exists } from '../misc' +import { exists, isArray } from '../misc' import { truncate } from 'lodash' import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' import { isHostValid } from '../servers' @@ -119,10 +119,15 @@ function isValidActorHandle (handle: string) { return isHostValid(parts[1]) } +function areValidActorHandles (handles: string[]) { + return isArray(handles) && handles.every(h => isValidActorHandle(h)) +} + // --------------------------------------------------------------------------- export { normalizeActor, + areValidActorHandles, isActorEndpointsObjectValid, isActorPublicKeyObjectValid, isActorTypeValid, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 46b63c5e9..9beb9b7c2 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -43,7 +43,8 @@ const SORTABLE_COLUMNS = { FOLLOWERS: [ 'createdAt' ], FOLLOWING: [ 'createdAt' ], - VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ] + VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ], + VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName' ] } const OAUTH_LIFETIME = { diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index 07a5ff92f..d2ad738a2 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts @@ -7,7 +7,7 @@ import { AccountModel } from '../../../models/account/account' import { ActorModel } from '../../../models/activitypub/actor' import { VideoChannelModel } from '../../../models/video/video-channel' import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' -import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannel, updateVideoFromAP } from '../videos' +import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos' import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' async function processUpdateActivity (activity: ActivityUpdate) { @@ -40,7 +40,7 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate) } const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id) - const channelActor = await getOrCreateVideoChannel(videoObject) + const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject) return updateVideoFromAP(video, videoObject, actor, channelActor, activity.to) } diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 388c31fe5..6c2095897 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -174,7 +174,7 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje return attributes } -function getOrCreateVideoChannel (videoObject: VideoTorrentObject) { +function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) { const channel = videoObject.attributedTo.find(a => a.type === 'Group') if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) @@ -251,7 +251,7 @@ async function getOrCreateVideoAndAccountAndChannel ( const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl) if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl) - const channelActor = await getOrCreateVideoChannel(fetchedVideo) + const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo) const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail) // Process outside the transaction because we could fetch remote data @@ -329,7 +329,7 @@ async function refreshVideoIfNeeded (video: VideoModel): Promise { return video } - const channelActor = await getOrCreateVideoChannel(videoObject) + const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject) const account = await AccountModel.load(channelActor.VideoChannel.accountId) return updateVideoFromAP(video, videoObject, account.Actor, channelActor) @@ -440,7 +440,7 @@ export { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes, createVideo, - getOrCreateVideoChannel, + getOrCreateVideoChannelFromVideoObject, addVideoShares, createRates } diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts index faefc1179..73fa28be9 100644 --- a/server/middlewares/validators/follows.ts +++ b/server/middlewares/validators/follows.ts @@ -38,7 +38,7 @@ const removeFollowingValidator = [ if (areValidationErrors(req, res)) return const serverActor = await getServerActor() - const follow = await ActorFollowModel.loadByActorAndTargetNameAndHost(serverActor.id, SERVER_ACTOR_NAME, req.params.host) + const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host) if (!follow) { return res diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts index e516c4c41..8baf643a5 100644 --- a/server/middlewares/validators/search.ts +++ b/server/middlewares/validators/search.ts @@ -5,7 +5,7 @@ import { query } from 'express-validator/check' import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' -const searchValidator = [ +const videosSearchValidator = [ query('search').optional().not().isEmpty().withMessage('Should have a valid search'), query('startDate').optional().custom(isDateValid).withMessage('Should have a valid start date'), @@ -15,7 +15,19 @@ const searchValidator = [ query('durationMax').optional().isInt().withMessage('Should have a valid max duration'), (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking search query', { parameters: req.query }) + logger.debug('Checking videos search query', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + +const videoChannelsSearchValidator = [ + query('search').not().isEmpty().withMessage('Should have a valid search'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking video channels search query', { parameters: req.query }) if (areValidationErrors(req, res)) return @@ -61,5 +73,6 @@ const commonVideosFiltersValidator = [ export { commonVideosFiltersValidator, - searchValidator + videoChannelsSearchValidator, + videosSearchValidator } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index b30e97e61..08dcc2680 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -8,6 +8,7 @@ const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS) const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES) const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) +const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) @@ -23,6 +24,7 @@ const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS) const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) +const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) @@ -45,5 +47,6 @@ export { followingSortValidator, jobsSortValidator, videoCommentThreadsSortValidator, - userSubscriptionsSortValidator + userSubscriptionsSortValidator, + videoChannelsSearchSortValidator } diff --git a/server/middlewares/validators/user-subscriptions.ts b/server/middlewares/validators/user-subscriptions.ts index d8c26c742..c5f8d9d4c 100644 --- a/server/middlewares/validators/user-subscriptions.ts +++ b/server/middlewares/validators/user-subscriptions.ts @@ -1,12 +1,13 @@ import * as express from 'express' import 'express-validator' -import { body, param } from 'express-validator/check' +import { body, param, query } from 'express-validator/check' import { logger } from '../../helpers/logger' import { areValidationErrors } from './utils' import { ActorFollowModel } from '../../models/activitypub/actor-follow' -import { isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' +import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' import { UserModel } from '../../models/account/user' import { CONFIG } from '../../initializers' +import { toArray } from '../../helpers/custom-validators/misc' const userSubscriptionAddValidator = [ body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'), @@ -20,6 +21,20 @@ const userSubscriptionAddValidator = [ } ] +const areSubscriptionsExistValidator = [ + query('uris') + .customSanitizer(toArray) + .custom(areValidActorHandles).withMessage('Should have a valid uri array'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking areSubscriptionsExistValidator parameters', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + const userSubscriptionGetValidator = [ param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'), @@ -32,7 +47,7 @@ const userSubscriptionGetValidator = [ if (host === CONFIG.WEBSERVER.HOST) host = null const user: UserModel = res.locals.oauth.token.User - const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHost(user.Account.Actor.id, name, host) + const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(user.Account.Actor.id, name, host) if (!subscription || !subscription.ActorFollowing.VideoChannel) { return res @@ -51,8 +66,7 @@ const userSubscriptionGetValidator = [ // --------------------------------------------------------------------------- export { + areSubscriptionsExistValidator, userSubscriptionAddValidator, userSubscriptionGetValidator } - -// --------------------------------------------------------------------------- diff --git a/server/models/account/account.ts b/server/models/account/account.ts index 07539a04e..6bbfc6f4e 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts @@ -29,18 +29,8 @@ import { UserModel } from './user' @DefaultScope({ include: [ { - model: () => ActorModel, - required: true, - include: [ - { - model: () => ServerModel, - required: false - }, - { - model: () => AvatarModel, - required: false - } - ] + model: () => ActorModel, // Default scope includes avatar and server + required: true } ] }) diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index b2d7ace66..81fcf7001 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts @@ -26,7 +26,7 @@ import { ACTOR_FOLLOW_SCORE } from '../../initializers' import { FOLLOW_STATES } from '../../initializers/constants' import { ServerModel } from '../server/server' import { getSort } from '../utils' -import { ActorModel } from './actor' +import { ActorModel, unusedActorAttributesForAPI } from './actor' import { VideoChannelModel } from '../video/video-channel' import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions' import { AccountModel } from '../account/account' @@ -167,8 +167,11 @@ export class ActorFollowModel extends Model { return ActorFollowModel.findOne(query) } - static loadByActorAndTargetNameAndHost (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) { + static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) { const actorFollowingPartInclude: IIncludeOptions = { + attributes: { + exclude: unusedActorAttributesForAPI + }, model: ActorModel, required: true, as: 'ActorFollowing', @@ -177,7 +180,7 @@ export class ActorFollowModel extends Model { }, include: [ { - model: VideoChannelModel, + model: VideoChannelModel.unscoped(), required: false } ] @@ -200,17 +203,79 @@ export class ActorFollowModel extends Model { actorId }, include: [ - { - model: ActorModel, - required: true, - as: 'ActorFollower' - }, actorFollowingPartInclude ], transaction: t } return ActorFollowModel.findOne(query) + .then(result => { + if (result && result.ActorFollowing.VideoChannel) { + result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing + } + + return result + }) + } + + static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]) { + const whereTab = targets + .map(t => { + if (t.host) { + return { + [ Sequelize.Op.and ]: [ + { + '$preferredUsername$': t.name + }, + { + '$host$': t.host + } + ] + } + } + + return { + [ Sequelize.Op.and ]: [ + { + '$preferredUsername$': t.name + }, + { + '$serverId$': null + } + ] + } + }) + + const query = { + attributes: [], + where: { + [ Sequelize.Op.and ]: [ + { + [ Sequelize.Op.or ]: whereTab + }, + { + actorId + } + ] + }, + include: [ + { + attributes: [ 'preferredUsername' ], + model: ActorModel.unscoped(), + required: true, + as: 'ActorFollowing', + include: [ + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + } + ] + } + ] + } + + return ActorFollowModel.findAll(query) } static listFollowingForApi (id: number, start: number, count: number, sort: string) { @@ -248,6 +313,7 @@ export class ActorFollowModel extends Model { static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { const query = { + attributes: [], distinct: true, offset: start, limit: count, @@ -257,6 +323,9 @@ export class ActorFollowModel extends Model { }, include: [ { + attributes: { + exclude: unusedActorAttributesForAPI + }, model: ActorModel, as: 'ActorFollowing', required: true, @@ -266,8 +335,24 @@ export class ActorFollowModel extends Model { required: true, include: [ { - model: AccountModel, + attributes: { + exclude: unusedActorAttributesForAPI + }, + model: ActorModel, required: true + }, + { + model: AccountModel, + required: true, + include: [ + { + attributes: { + exclude: unusedActorAttributesForAPI + }, + model: ActorModel, + required: true + } + ] } ] } diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 2abf40713..ec0b4b2d9 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -42,6 +42,16 @@ enum ScopeNames { FULL = 'FULL' } +export const unusedActorAttributesForAPI = [ + 'publicKey', + 'privateKey', + 'inboxUrl', + 'outboxUrl', + 'sharedInboxUrl', + 'followersUrl', + 'followingUrl' +] + @DefaultScope({ include: [ { diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 9f80e0b8d..7d717fc68 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -12,6 +12,7 @@ import { Is, Model, Scopes, + Sequelize, Table, UpdatedAt } from 'sequelize-typescript' @@ -24,19 +25,36 @@ import { } from '../../helpers/custom-validators/video-channels' import { sendDeleteActor } from '../../lib/activitypub/send' import { AccountModel } from '../account/account' -import { ActorModel } from '../activitypub/actor' -import { getSort, throwIfNotValid } from '../utils' +import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' +import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' import { VideoModel } from './video' import { CONSTRAINTS_FIELDS } from '../../initializers' -import { AvatarModel } from '../avatar/avatar' import { ServerModel } from '../server/server' +import { DefineIndexesOptions } from 'sequelize' + +// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation +const indexes: DefineIndexesOptions[] = [ + buildTrigramSearchIndex('video_channel_name_trigram', 'name'), + + { + fields: [ 'accountId' ] + }, + { + fields: [ 'actorId' ] + } +] enum ScopeNames { + AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACTOR = 'WITH_ACTOR', WITH_VIDEOS = 'WITH_VIDEOS' } +type AvailableForListOptions = { + actorId: number +} + @DefaultScope({ include: [ { @@ -46,23 +64,57 @@ enum ScopeNames { ] }) @Scopes({ - [ScopeNames.WITH_ACCOUNT]: { - include: [ - { - model: () => AccountModel.unscoped(), - required: true, - include: [ - { - model: () => ActorModel.unscoped(), - required: true, - include: [ + [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { + const actorIdNumber = parseInt(options.actorId + '', 10) + + // Only list local channels OR channels that are on an instance followed by actorId + const inQueryInstanceFollow = '(' + + 'SELECT "actor"."serverId" FROM "actor" ' + + 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = actor.id ' + + 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + + ')' + + return { + include: [ + { + attributes: { + exclude: unusedActorAttributesForAPI + }, + model: ActorModel, + where: { + [Sequelize.Op.or]: [ { - model: () => AvatarModel.unscoped(), - required: false + serverId: null + }, + { + serverId: { + [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) + } } ] } - ] + }, + { + model: AccountModel, + required: true, + include: [ + { + attributes: { + exclude: unusedActorAttributesForAPI + }, + model: ActorModel, // Default scope includes avatar and server + required: true + } + ] + } + ] + } + }, + [ScopeNames.WITH_ACCOUNT]: { + include: [ + { + model: () => AccountModel, + required: true } ] }, @@ -79,14 +131,7 @@ enum ScopeNames { }) @Table({ tableName: 'videoChannel', - indexes: [ - { - fields: [ 'accountId' ] - }, - { - fields: [ 'actorId' ] - } - ] + indexes }) export class VideoChannelModel extends Model { @@ -170,15 +215,61 @@ export class VideoChannelModel extends Model { return VideoChannelModel.count(query) } - static listForApi (start: number, count: number, sort: string) { + static listForApi (actorId: number, start: number, count: number, sort: string) { const query = { offset: start, limit: count, order: getSort(sort) } + const scopes = { + method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ] + } return VideoChannelModel - .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) + .scope(scopes) + .findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows } + }) + } + + static searchForApi (options: { + actorId: number + search: string + start: number + count: number + sort: string + }) { + const attributesInclude = [] + const escapedSearch = VideoModel.sequelize.escape(options.search) + const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') + attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search)) + + const query = { + attributes: { + include: attributesInclude + }, + offset: options.start, + limit: options.count, + order: getSort(options.sort), + where: { + id: { + [ Sequelize.Op.in ]: Sequelize.literal( + '(' + + 'SELECT id FROM "videoChannel" WHERE ' + + 'lower(immutable_unaccent("name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + + 'lower(immutable_unaccent("name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + + ')' + ) + } + } + } + + const scopes = { + method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: options.actorId } as AvailableForListOptions ] + } + return VideoChannelModel + .scope(scopes) .findAndCountAll(query) .then(({ rows, count }) => { return { total: count, data: rows } @@ -239,7 +330,25 @@ export class VideoChannelModel extends Model { } return VideoChannelModel - .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) + .scope([ ScopeNames.WITH_ACCOUNT ]) + .findOne(query) + } + + static loadByUrlAndPopulateAccount (url: string) { + const query = { + include: [ + { + model: ActorModel, + required: true, + where: { + url + } + } + ] + } + + return VideoChannelModel + .scope([ ScopeNames.WITH_ACCOUNT ]) .findOne(query) } diff --git a/server/tests/api/check-params/user-subscriptions.ts b/server/tests/api/check-params/user-subscriptions.ts index 6a6dd9a6f..9fba99ac8 100644 --- a/server/tests/api/check-params/user-subscriptions.ts +++ b/server/tests/api/check-params/user-subscriptions.ts @@ -202,6 +202,46 @@ describe('Test user subscriptions API validators', function () { }) }) + describe('When checking if subscriptions exist', async function () { + const existPath = path + '/exist' + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path: existPath, + statusCodeExpected: 401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makeGetRequest({ + url: server.url, + path: existPath, + query: { uris: 'toto' }, + token: server.accessToken, + statusCodeExpected: 400 + }) + + await makeGetRequest({ + url: server.url, + path: existPath, + query: { 'uris[]': 1 }, + token: server.accessToken, + statusCodeExpected: 400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path: existPath, + query: { 'uris[]': 'coucou@localhost:9001' }, + token: server.accessToken, + statusCodeExpected: 200 + }) + }) + }) + describe('When removing a subscription', function () { it('Should fail with a non authenticated user', async function () { await makeDeleteRequest({ diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts index cb7d94b0b..65b80540c 100644 --- a/server/tests/api/users/user-subscriptions.ts +++ b/server/tests/api/users/user-subscriptions.ts @@ -12,7 +12,7 @@ import { listUserSubscriptions, listUserSubscriptionVideos, removeUserSubscription, - getUserSubscription + getUserSubscription, areSubscriptionsExist } from '../../utils/users/user-subscriptions' const expect = chai.expect @@ -128,6 +128,23 @@ describe('Test users subscriptions', function () { } }) + it('Should return the existing subscriptions', async function () { + const uris = [ + 'user3_channel@localhost:9003', + 'root2_channel@localhost:9001', + 'root_channel@localhost:9001', + 'user3_channel@localhost:9001' + ] + + const res = await areSubscriptionsExist(servers[ 0 ].url, users[ 0 ].accessToken, uris) + const body = res.body + + expect(body['user3_channel@localhost:9003']).to.be.true + expect(body['root2_channel@localhost:9001']).to.be.false + expect(body['root_channel@localhost:9001']).to.be.true + expect(body['user3_channel@localhost:9001']).to.be.false + }) + it('Should list subscription videos', async function () { { const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken) diff --git a/server/tests/utils/users/user-subscriptions.ts b/server/tests/utils/users/user-subscriptions.ts index 852f590cf..b0e7da7cc 100644 --- a/server/tests/utils/users/user-subscriptions.ts +++ b/server/tests/utils/users/user-subscriptions.ts @@ -58,9 +58,22 @@ function removeUserSubscription (url: string, token: string, uri: string, status }) } +function areSubscriptionsExist (url: string, token: string, uris: string[], statusCodeExpected = 200) { + const path = '/api/v1/users/me/subscriptions/exist' + + return makeGetRequest({ + url, + path, + query: { 'uris[]': uris }, + token, + statusCodeExpected + }) +} + // --------------------------------------------------------------------------- export { + areSubscriptionsExist, addUserSubscription, listUserSubscriptions, getUserSubscription, diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts index 928846c39..28dd95443 100644 --- a/shared/models/search/index.ts +++ b/shared/models/search/index.ts @@ -1,2 +1,3 @@ export * from './nsfw-query.model' export * from './videos-search-query.model' +export * from './video-channels-search-query.model' diff --git a/shared/models/search/video-channels-search-query.model.ts b/shared/models/search/video-channels-search-query.model.ts new file mode 100644 index 000000000..de2741e14 --- /dev/null +++ b/shared/models/search/video-channels-search-query.model.ts @@ -0,0 +1,7 @@ +export interface VideoChannelsSearchQuery { + search: string + + start?: number + count?: number + sort?: string +}