From f37dc0dd14d9ce0b59c454c2c1b935fcbe9727e9 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 23 Aug 2018 17:58:39 +0200 Subject: Add ability to search video channels --- server/controllers/api/search.ts | 96 ++++++++++-- server/controllers/api/users/me.ts | 41 +++++- server/controllers/api/video-channel.ts | 5 +- .../helpers/custom-validators/activitypub/actor.ts | 7 +- server/initializers/constants.ts | 3 +- server/lib/activitypub/process/process-update.ts | 4 +- server/lib/activitypub/videos.ts | 8 +- server/middlewares/validators/follows.ts | 2 +- server/middlewares/validators/search.ts | 19 ++- server/middlewares/validators/sort.ts | 5 +- .../middlewares/validators/user-subscriptions.ts | 24 ++- server/models/account/account.ts | 14 +- server/models/activitypub/actor-follow.ts | 103 +++++++++++-- server/models/activitypub/actor.ts | 10 ++ server/models/video/video-channel.ts | 163 +++++++++++++++++---- .../tests/api/check-params/user-subscriptions.ts | 40 +++++ server/tests/api/users/user-subscriptions.ts | 19 ++- server/tests/utils/users/user-subscriptions.ts | 13 ++ 18 files changed, 490 insertions(+), 86 deletions(-) (limited to 'server') 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]: [ + { + serverId: null + }, { - model: () => AvatarModel.unscoped(), - required: false + 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, -- cgit v1.2.3