import * as Bluebird from 'bluebird' import { values } from 'lodash' import * as Sequelize from 'sequelize' import { AfterCreate, AfterDestroy, AfterUpdate, AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IsInt, Max, Model, Table, UpdatedAt } from 'sequelize-typescript' import { FollowState } from '../../../shared/models/actors' import { ActorFollow } from '../../../shared/models/actors/follow.model' import { logger } from '../../helpers/logger' import { getServerActor } from '../../helpers/utils' import { ACTOR_FOLLOW_SCORE } from '../../initializers' import { FOLLOW_STATES } from '../../initializers/constants' import { ServerModel } from '../server/server' import { getSort } from '../utils' 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' @Table({ tableName: 'actorFollow', indexes: [ { fields: [ 'actorId' ] }, { fields: [ 'targetActorId' ] }, { fields: [ 'actorId', 'targetActorId' ], unique: true }, { fields: [ 'score' ] } ] }) export class ActorFollowModel extends Model { @AllowNull(false) @Column(DataType.ENUM(values(FOLLOW_STATES))) state: FollowState @AllowNull(false) @Default(ACTOR_FOLLOW_SCORE.BASE) @IsInt @Max(ACTOR_FOLLOW_SCORE.MAX) @Column score: number @CreatedAt createdAt: Date @UpdatedAt updatedAt: Date @ForeignKey(() => ActorModel) @Column actorId: number @BelongsTo(() => ActorModel, { foreignKey: { name: 'actorId', allowNull: false }, as: 'ActorFollower', onDelete: 'CASCADE' }) ActorFollower: ActorModel @ForeignKey(() => ActorModel) @Column targetActorId: number @BelongsTo(() => ActorModel, { foreignKey: { name: 'targetActorId', allowNull: false }, as: 'ActorFollowing', onDelete: 'CASCADE' }) ActorFollowing: ActorModel @AfterCreate @AfterUpdate static incrementFollowerAndFollowingCount (instance: ActorFollowModel) { if (instance.state !== 'accepted') return undefined return Promise.all([ ActorModel.incrementFollows(instance.actorId, 'followingCount', 1), ActorModel.incrementFollows(instance.targetActorId, 'followersCount', 1) ]) } @AfterDestroy static decrementFollowerAndFollowingCount (instance: ActorFollowModel) { return Promise.all([ ActorModel.incrementFollows(instance.actorId, 'followingCount',-1), ActorModel.incrementFollows(instance.targetActorId, 'followersCount', -1) ]) } // Remove actor follows with a score of 0 (too many requests where they were unreachable) static async removeBadActorFollows () { const actorFollows = await ActorFollowModel.listBadActorFollows() const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy()) await Promise.all(actorFollowsRemovePromises) const numberOfActorFollowsRemoved = actorFollows.length if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved) } static updateActorFollowsScore (goodInboxes: string[], badInboxes: string[], t: Sequelize.Transaction | undefined) { if (goodInboxes.length === 0 && badInboxes.length === 0) return logger.info('Updating %d good actor follows and %d bad actor follows scores.', goodInboxes.length, badInboxes.length) if (goodInboxes.length !== 0) { ActorFollowModel.incrementScores(goodInboxes, ACTOR_FOLLOW_SCORE.BONUS, t) .catch(err => logger.error('Cannot increment scores of good actor follows.', { err })) } if (badInboxes.length !== 0) { ActorFollowModel.incrementScores(badInboxes, ACTOR_FOLLOW_SCORE.PENALTY, t) .catch(err => logger.error('Cannot decrement scores of bad actor follows.', { err })) } } static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) { const query = { where: { actorId, targetActorId: targetActorId }, include: [ { model: ActorModel, required: true, as: 'ActorFollower' }, { model: ActorModel, required: true, as: 'ActorFollowing' } ], transaction: t } return ActorFollowModel.findOne(query) } static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) { const actorFollowingPartInclude: IIncludeOptions = { model: ActorModel, required: true, as: 'ActorFollowing', where: { preferredUsername: targetName }, include: [ { model: VideoChannelModel.unscoped(), required: false } ] } if (targetHost === null) { actorFollowingPartInclude.where['serverId'] = null } else { actorFollowingPartInclude.include.push({ model: ServerModel, required: true, where: { host: targetHost } }) } const query = { where: { actorId }, include: [ actorFollowingPartInclude, { model: ActorModel, required: true, as: 'ActorFollower' } ], 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, search?: string) { const query = { distinct: true, offset: start, limit: count, order: getSort(sort), include: [ { model: ActorModel, required: true, as: 'ActorFollower', where: { id } }, { model: ActorModel, as: 'ActorFollowing', required: true, include: [ { model: ServerModel, required: true, where: search ? { host: { [Sequelize.Op.iLike]: '%' + search + '%' } } : undefined } ] } ] } return ActorFollowModel.findAndCountAll(query) .then(({ rows, count }) => { return { data: rows, total: count } }) } static listFollowersForApi (id: number, start: number, count: number, sort: string, search?: string) { const query = { distinct: true, offset: start, limit: count, order: getSort(sort), include: [ { model: ActorModel, required: true, as: 'ActorFollower', include: [ { model: ServerModel, required: true, where: search ? { host: { [ Sequelize.Op.iLike ]: '%' + search + '%' } } : undefined } ] }, { model: ActorModel, as: 'ActorFollowing', required: true, where: { id } } ] } return ActorFollowModel.findAndCountAll(query) .then(({ rows, count }) => { return { data: rows, total: count } }) } static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { const query = { attributes: [], distinct: true, offset: start, limit: count, order: getSort(sort), where: { actorId: id }, include: [ { attributes: [ 'id' ], model: ActorModel.unscoped(), as: 'ActorFollowing', required: true, include: [ { model: VideoChannelModel.unscoped(), required: true, include: [ { attributes: { exclude: unusedActorAttributesForAPI }, model: ActorModel, required: true }, { model: AccountModel.unscoped(), required: true, include: [ { attributes: { exclude: unusedActorAttributesForAPI }, model: ActorModel, required: true } ] } ] } ] } ] } return ActorFollowModel.findAndCountAll(query) .then(({ rows, count }) => { return { data: rows.map(r => r.ActorFollowing.VideoChannel), total: count } }) } static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) } static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Sequelize.Transaction) { return ActorFollowModel.createListAcceptedFollowForApiQuery( 'followers', actorIds, t, undefined, undefined, 'sharedInboxUrl', true ) } static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count) } static async getStats () { const serverActor = await getServerActor() const totalInstanceFollowing = await ActorFollowModel.count({ where: { actorId: serverActor.id } }) const totalInstanceFollowers = await ActorFollowModel.count({ where: { targetActorId: serverActor.id } }) return { totalInstanceFollowing, totalInstanceFollowers } } private static async createListAcceptedFollowForApiQuery ( type: 'followers' | 'following', actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number, columnUrl = 'url', distinct = false ) { let firstJoin: string let secondJoin: string if (type === 'followers') { firstJoin = 'targetActorId' secondJoin = 'actorId' } else { firstJoin = 'actorId' secondJoin = 'targetActorId' } const selections: string[] = [] if (distinct === true) selections.push('DISTINCT("Follows"."' + columnUrl + '") AS "url"') else selections.push('"Follows"."' + columnUrl + '" AS "url"') selections.push('COUNT(*) AS "total"') const tasks: Bluebird[] = [] for (let selection of selections) { let query = 'SELECT ' + selection + ' FROM "actor" ' + 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' + 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' + 'WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = \'accepted\' ' if (count !== undefined) query += 'LIMIT ' + count if (start !== undefined) query += ' OFFSET ' + start const options = { bind: { actorIds }, type: Sequelize.QueryTypes.SELECT, transaction: t } tasks.push(ActorFollowModel.sequelize.query(query, options)) } const [ followers, [ dataTotal ] ] = await Promise.all(tasks) const urls: string[] = followers.map(f => f.url) return { data: urls, total: dataTotal ? parseInt(dataTotal.total, 10) : 0 } } private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction | undefined) { const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',') const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` + 'WHERE id IN (' + 'SELECT "actorFollow"."id" FROM "actorFollow" ' + 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' + 'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' + ')' const options = t ? { type: Sequelize.QueryTypes.BULKUPDATE, transaction: t } : undefined return ActorFollowModel.sequelize.query(query, options) } private static listBadActorFollows () { const query = { where: { score: { [Sequelize.Op.lte]: 0 } }, logging: false } return ActorFollowModel.findAll(query) } toFormattedJSON (): ActorFollow { const follower = this.ActorFollower.toFormattedJSON() const following = this.ActorFollowing.toFormattedJSON() return { id: this.id, follower, following, score: this.score, state: this.state, createdAt: this.createdAt, updatedAt: this.updatedAt } } }