X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Factivitypub%2Factor.ts;h=19f3f7e04ef67279d33e5b66fb78284552302eb6;hb=91723454849076387176684f0d9c73deab824e20;hp=4cae6a6ecfe0cb8b7cad61635574138812b08bfc;hpb=fadf619ad61a016c1c7fc53de5a8f398a4f77519;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 4cae6a6ec..19f3f7e04 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -1,54 +1,190 @@ -import { join } from 'path' -import * as Sequelize from 'sequelize' +import { values } from 'lodash' +import { extname } from 'path' +import { literal, Op, Transaction } from 'sequelize' import { - AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, Is, IsUUID, Model, Table, + AllowNull, + BelongsTo, + Column, + CreatedAt, + DataType, + DefaultScope, + ForeignKey, + HasMany, + HasOne, + Is, + Model, + Scopes, + Table, UpdatedAt } from 'sequelize-typescript' -import { Avatar } from '../../../shared/models/avatars/avatar.model' -import { activityPubContextify } from '../../helpers' +import { ModelCache } from '@server/models/model-cache' +import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub' +import { ActorImage } from '../../../shared/models/actors/actor-image.model' +import { activityPubContextify } from '../../helpers/activitypub' import { - isActivityPubUrlValid, isActorFollowersCountValid, - isActorFollowingCountValid, isActorPreferredUsernameValid, + isActorFollowingCountValid, + isActorPreferredUsernameValid, isActorPrivateKeyValid, isActorPublicKeyValid -} from '../../helpers/custom-validators/activitypub' -import { isUserUsernameValid } from '../../helpers/custom-validators/users' -import { AVATARS_DIR, CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' -import { AccountFollowModel } from '../account/account-follow' -import { AvatarModel } from '../avatar/avatar' +} from '../../helpers/custom-validators/activitypub/actor' +import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' +import { + ACTIVITY_PUB, + ACTIVITY_PUB_ACTOR_TYPES, + CONSTRAINTS_FIELDS, + MIMETYPES, + SERVER_ACTOR_NAME, + WEBSERVER +} from '../../initializers/constants' +import { + MActor, + MActorAccountChannelId, + MActorAPAccount, + MActorAPChannel, + MActorFormattable, + MActorFull, + MActorHost, + MActorServer, + MActorSummaryFormattable, + MActorUrl, + MActorWithInboxes +} from '../../types/models' +import { AccountModel } from '../account/account' +import { ActorImageModel } from '../account/actor-image' import { ServerModel } from '../server/server' -import { throwIfNotValid } from '../utils' +import { isOutdated, throwIfNotValid } from '../utils' +import { VideoModel } from '../video/video' +import { VideoChannelModel } from '../video/video-channel' +import { ActorFollowModel } from './actor-follow' + +enum ScopeNames { + FULL = 'FULL' +} +export const unusedActorAttributesForAPI = [ + 'publicKey', + 'privateKey', + 'inboxUrl', + 'outboxUrl', + 'sharedInboxUrl', + 'followersUrl', + 'followingUrl', + 'createdAt', + 'updatedAt' +] + +@DefaultScope(() => ({ + include: [ + { + model: ServerModel, + required: false + }, + { + model: ActorImageModel, + as: 'Avatar', + required: false + } + ] +})) +@Scopes(() => ({ + [ScopeNames.FULL]: { + include: [ + { + model: AccountModel.unscoped(), + required: false + }, + { + model: VideoChannelModel.unscoped(), + required: false, + include: [ + { + model: AccountModel, + required: true + } + ] + }, + { + model: ServerModel, + required: false + }, + { + model: ActorImageModel, + as: 'Avatar', + required: false + }, + { + model: ActorImageModel, + as: 'Banner', + required: false + } + ] + } +})) @Table({ - tableName: 'actor' + tableName: 'actor', + indexes: [ + { + fields: [ 'url' ], + unique: true + }, + { + fields: [ 'preferredUsername', 'serverId' ], + unique: true, + where: { + serverId: { + [Op.ne]: null + } + } + }, + { + fields: [ 'preferredUsername' ], + unique: true, + where: { + serverId: null + } + }, + { + fields: [ 'inboxUrl', 'sharedInboxUrl' ] + }, + { + fields: [ 'sharedInboxUrl' ] + }, + { + fields: [ 'serverId' ] + }, + { + fields: [ 'avatarId' ] + }, + { + fields: [ 'followersUrl' ] + } + ] }) -export class ActorModel extends Model { +export class ActorModel extends Model { @AllowNull(false) - @Default(DataType.UUIDV4) - @IsUUID(4) - @Column(DataType.UUID) - uuid: string + @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES))) + type: ActivityPubActorType @AllowNull(false) - @Is('ActorName', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor name')) + @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username')) @Column - name: string + preferredUsername: string @AllowNull(false) @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.URL.max)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max)) url: string @AllowNull(true) - @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.PUBLIC_KEY.max)) + @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max)) publicKey: string @AllowNull(true) - @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.PRIVATE_KEY.max)) + @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max)) privateKey: string @AllowNull(false) @@ -63,27 +199,27 @@ export class ActorModel extends Model { @AllowNull(false) @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.URL.max)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max)) inboxUrl: string - @AllowNull(false) - @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.URL.max)) + @AllowNull(true) + @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max)) outboxUrl: string - @AllowNull(false) - @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.URL.max)) + @AllowNull(true) + @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max)) sharedInboxUrl: string - @AllowNull(false) - @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.URL.max)) + @AllowNull(true) + @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max)) followersUrl: string - @AllowNull(false) - @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url')) - @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTOR.URL.max)) + @AllowNull(true) + @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max)) followingUrl: string @CreatedAt @@ -92,36 +228,55 @@ export class ActorModel extends Model { @UpdatedAt updatedAt: Date - @ForeignKey(() => AvatarModel) + @ForeignKey(() => ActorImageModel) @Column avatarId: number - @BelongsTo(() => AvatarModel, { + @ForeignKey(() => ActorImageModel) + @Column + bannerId: number + + @BelongsTo(() => ActorImageModel, { foreignKey: { + name: 'avatarId', allowNull: true }, - onDelete: 'cascade' + as: 'Avatar', + onDelete: 'set null', + hooks: true }) - Avatar: AvatarModel + Avatar: ActorImageModel - @HasMany(() => AccountFollowModel, { + @BelongsTo(() => ActorImageModel, { foreignKey: { - name: 'accountId', + name: 'bannerId', + allowNull: true + }, + as: 'Banner', + onDelete: 'set null', + hooks: true + }) + Banner: ActorImageModel + + @HasMany(() => ActorFollowModel, { + foreignKey: { + name: 'actorId', allowNull: false }, + as: 'ActorFollowings', onDelete: 'cascade' }) - AccountFollowing: AccountFollowModel[] + ActorFollowing: ActorFollowModel[] - @HasMany(() => AccountFollowModel, { + @HasMany(() => ActorFollowModel, { foreignKey: { - name: 'targetAccountId', + name: 'targetActorId', allowNull: false }, - as: 'followers', + as: 'ActorFollowers', onDelete: 'cascade' }) - AccountFollowers: AccountFollowModel[] + ActorFollowers: ActorFollowModel[] @ForeignKey(() => ServerModel) @Column @@ -135,88 +290,343 @@ export class ActorModel extends Model { }) Server: ServerModel - static listByFollowersUrls (followersUrls: string[], transaction?: Sequelize.Transaction) { + @HasOne(() => AccountModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade', + hooks: true + }) + Account: AccountModel + + @HasOne(() => VideoChannelModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade', + hooks: true + }) + VideoChannel: VideoChannelModel + + static load (id: number): Promise { + return ActorModel.unscoped().findByPk(id) + } + + static loadFull (id: number): Promise { + return ActorModel.scope(ScopeNames.FULL).findByPk(id) + } + + static loadFromAccountByVideoId (videoId: number, transaction: Transaction): Promise { + const query = { + include: [ + { + attributes: [ 'id' ], + model: AccountModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'id' ], + model: VideoChannelModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'id' ], + model: VideoModel.unscoped(), + required: true, + where: { + id: videoId + } + } + ] + } + ] + } + ], + transaction + } + + return ActorModel.unscoped().findOne(query) + } + + static isActorUrlExist (url: string) { + const query = { + raw: true, + where: { + url + } + } + + return ActorModel.unscoped().findOne(query) + .then(a => !!a) + } + + static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Promise { const query = { where: { followersUrl: { - [ Sequelize.Op.in ]: followersUrls + [Op.in]: followersUrls } }, transaction } - return ActorModel.findAll(query) + return ActorModel.scope(ScopeNames.FULL).findAll(query) } - toFormattedJSON () { - let avatar: Avatar = null - if (this.Avatar) { - avatar = { - path: join(AVATARS_DIR.ACCOUNT, this.Avatar.filename), - createdAt: this.Avatar.createdAt, - updatedAt: this.Avatar.updatedAt + static loadLocalByName (preferredUsername: string, transaction?: Transaction): Promise { + const fun = () => { + const query = { + where: { + preferredUsername, + serverId: null + }, + transaction } + + return ActorModel.scope(ScopeNames.FULL) + .findOne(query) } - let host = CONFIG.WEBSERVER.HOST - let score: number - if (this.Server) { - host = this.Server.host - score = this.Server.score + return ModelCache.Instance.doCache({ + cacheType: 'local-actor-name', + key: preferredUsername, + // The server actor never change, so we can easily cache it + whitelist: () => preferredUsername === SERVER_ACTOR_NAME, + fun + }) + } + + static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Promise { + const fun = () => { + const query = { + attributes: [ 'url' ], + where: { + preferredUsername, + serverId: null + }, + transaction + } + + return ActorModel.unscoped() + .findOne(query) + } + + return ModelCache.Instance.doCache({ + cacheType: 'local-actor-name', + key: preferredUsername, + // The server actor never change, so we can easily cache it + whitelist: () => preferredUsername === SERVER_ACTOR_NAME, + fun + }) + } + + static loadByNameAndHost (preferredUsername: string, host: string): Promise { + const query = { + where: { + preferredUsername + }, + include: [ + { + model: ServerModel, + required: true, + where: { + host + } + } + ] + } + + return ActorModel.scope(ScopeNames.FULL).findOne(query) + } + + static loadByUrl (url: string, transaction?: Transaction): Promise { + const query = { + where: { + url + }, + transaction, + include: [ + { + attributes: [ 'id' ], + model: AccountModel.unscoped(), + required: false + }, + { + attributes: [ 'id' ], + model: VideoChannelModel.unscoped(), + required: false + } + ] + } + + return ActorModel.unscoped().findOne(query) + } + + static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Promise { + const query = { + where: { + url + }, + transaction + } + + return ActorModel.scope(ScopeNames.FULL).findOne(query) + } + + static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) { + const sanitizedOfId = parseInt(ofId + '', 10) + const where = { id: sanitizedOfId } + + let columnToUpdate: string + let columnOfCount: string + + if (type === 'followers') { + columnToUpdate = 'followersCount' + columnOfCount = 'targetActorId' + } else { + columnToUpdate = 'followingCount' + columnOfCount = 'actorId' + } + + return ActorModel.update({ + [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId})`) + }, { where, transaction }) + } + + static loadAccountActorByVideoId (videoId: number): Promise { + const query = { + include: [ + { + attributes: [ 'id' ], + model: AccountModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'id', 'accountId' ], + model: VideoChannelModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'id', 'channelId' ], + model: VideoModel.unscoped(), + where: { + id: videoId + } + } + ] + } + ] + } + ] + } + + return ActorModel.unscoped().findOne(query) + } + + getSharedInbox (this: MActorWithInboxes) { + return this.sharedInboxUrl || this.inboxUrl + } + + toFormattedSummaryJSON (this: MActorSummaryFormattable) { + let avatar: ActorImage = null + if (this.Avatar) { + avatar = this.Avatar.toFormattedJSON() } return { + url: this.url, + name: this.preferredUsername, + host: this.getHost(), + avatar + } + } + + toFormattedJSON (this: MActorFormattable) { + const base = this.toFormattedSummaryJSON() + + let banner: ActorImage = null + if (this.Banner) { + banner = this.Banner.toFormattedJSON() + } + + return Object.assign(base, { id: this.id, - host, - score, + hostRedundancyAllowed: this.getRedundancyAllowed(), followingCount: this.followingCount, followersCount: this.followersCount, - avatar - } + banner, + createdAt: this.createdAt, + updatedAt: this.updatedAt + }) } - toActivityPubObject (name: string, uuid: string, type: 'Account' | 'VideoChannel') { - let activityPubType - if (type === 'Account') { - activityPubType = this.serverId ? 'Application' as 'Application' : 'Person' as 'Person' - } else { // VideoChannel - activityPubType = 'Group' + toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) { + let icon: ActivityIconObject + let image: ActivityIconObject + + if (this.avatarId) { + const extension = extname(this.Avatar.filename) + + icon = { + type: 'Image', + mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension], + height: this.Avatar.height, + width: this.Avatar.width, + url: this.getAvatarUrl() + } + } + + if (this.bannerId) { + const banner = (this as MActorAPChannel).Banner + const extension = extname(banner.filename) + + image = { + type: 'Image', + mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension], + height: banner.height, + width: banner.width, + url: this.getBannerUrl() + } } const json = { - type, + type: this.type, id: this.url, following: this.getFollowingUrl(), followers: this.getFollowersUrl(), + playlists: this.getPlaylistsUrl(), inbox: this.inboxUrl, outbox: this.outboxUrl, - preferredUsername: name, + preferredUsername: this.preferredUsername, url: this.url, name, endpoints: { sharedInbox: this.sharedInboxUrl }, - uuid, publicKey: { id: this.getPublicKeyUrl(), owner: this.url, publicKeyPem: this.publicKey - } + }, + icon, + image } return activityPubContextify(json) } - getFollowerSharedInboxUrls (t: Sequelize.Transaction) { + getFollowerSharedInboxUrls (t: Transaction) { const query = { attributes: [ 'sharedInboxUrl' ], include: [ { - model: AccountFollowModel, + attribute: [], + model: ActorFollowModel.unscoped(), required: true, - as: 'followers', + as: 'ActorFollowing', where: { - targetAccountId: this.id + state: 'accepted', + targetActorId: this.id } } ], @@ -235,6 +645,10 @@ export class ActorModel extends Model { return this.url + '/followers' } + getPlaylistsUrl () { + return this.url + '/playlists' + } + getPublicKeyUrl () { return this.url + '#main-key' } @@ -242,4 +656,38 @@ export class ActorModel extends Model { isOwned () { return this.serverId === null } + + getWebfingerUrl (this: MActorServer) { + return 'acct:' + this.preferredUsername + '@' + this.getHost() + } + + getIdentifier () { + return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername + } + + getHost (this: MActorHost) { + return this.Server ? this.Server.host : WEBSERVER.HOST + } + + getRedundancyAllowed () { + return this.Server ? this.Server.redundancyAllowed : false + } + + getAvatarUrl () { + if (!this.avatarId) return undefined + + return WEBSERVER.URL + this.Avatar.getStaticPath() + } + + getBannerUrl () { + if (!this.bannerId) return undefined + + return WEBSERVER.URL + this.Banner.getStaticPath() + } + + isOutdated () { + if (this.isOwned()) return false + + return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL) + } }