From d0800f7661f13fabe7bb6f4aa0ea50764f106405 Mon Sep 17 00:00:00 2001 From: kontrollanten <6680299+kontrollanten@users.noreply.github.com> Date: Mon, 28 Feb 2022 08:34:43 +0100 Subject: Implement avatar miniatures (#4639) * client: remove unused file * refactor(client/my-actor-avatar): size from input Read size from component input instead of scss, to make it possible to use smaller avatar images when implemented. * implement avatar miniatures close #4560 * fix(test): max file size * fix(search-index): normalize res acc to avatarMini * refactor avatars to an array * client/search: resize channel avatar to 120 * refactor(client/videos): remove unused function * client(actor-avatar): set default size * fix tests and avatars full result When findOne is used only an array containting one avatar is returned. * update migration version and version notations * server/search: harmonize normalizing * Cleanup avatar miniature PR Co-authored-by: Chocobozzz --- server/lib/activitypub/actors/image.ts | 89 ++++++++++++---------- server/lib/activitypub/actors/shared/creator.ts | 16 ++-- .../actors/shared/object-to-model-attributes.ts | 56 ++++++++------ server/lib/activitypub/actors/updater.ts | 12 +-- server/lib/actor-image.ts | 14 ++++ server/lib/client-html.ts | 10 ++- server/lib/local-actor.ts | 89 ++++++++++++---------- .../lib/notifier/shared/comment/comment-mention.ts | 2 +- .../shared/comment/new-comment-for-video-owner.ts | 2 +- 9 files changed, 165 insertions(+), 125 deletions(-) create mode 100644 server/lib/actor-image.ts (limited to 'server/lib') diff --git a/server/lib/activitypub/actors/image.ts b/server/lib/activitypub/actors/image.ts index 443ad0a63..d17c2ef1a 100644 --- a/server/lib/activitypub/actors/image.ts +++ b/server/lib/activitypub/actors/image.ts @@ -12,53 +12,52 @@ type ImageInfo = { onDisk?: boolean } -async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) { - const oldImageModel = type === ActorImageType.AVATAR - ? actor.Avatar - : actor.Banner +async function updateActorImages (actor: MActorImages, type: ActorImageType, imagesInfo: ImageInfo[], t: Transaction) { + const avatarsOrBanners = type === ActorImageType.AVATAR + ? actor.Avatars + : actor.Banners - if (oldImageModel) { - // Don't update the avatar if the file URL did not change - if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) return actor + if (imagesInfo.length === 0) { + await deleteActorImages(actor, type, t) + } + + for (const imageInfo of imagesInfo) { + const oldImageModel = (avatarsOrBanners || []).find(i => i.width === imageInfo.width) - try { - await oldImageModel.destroy({ transaction: t }) + if (oldImageModel) { + // Don't update the avatar if the file URL did not change + if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) { + continue + } - setActorImage(actor, type, null) - } catch (err) { - logger.error('Cannot remove old actor image of actor %s.', actor.url, { err }) + await safeDeleteActorImage(actor, oldImageModel, type, t) } - } - if (imageInfo) { const imageModel = await ActorImageModel.create({ filename: imageInfo.name, onDisk: imageInfo.onDisk ?? false, fileUrl: imageInfo.fileUrl, height: imageInfo.height, width: imageInfo.width, - type + type, + actorId: actor.id }, { transaction: t }) - setActorImage(actor, type, imageModel) + addActorImage(actor, type, imageModel) } return actor } -async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) { +async function deleteActorImages (actor: MActorImages, type: ActorImageType, t: Transaction) { try { - if (type === ActorImageType.AVATAR) { - await actor.Avatar.destroy({ transaction: t }) - - actor.avatarId = null - actor.Avatar = null - } else { - await actor.Banner.destroy({ transaction: t }) + const association = buildAssociationName(type) - actor.bannerId = null - actor.Banner = null + for (const image of actor[association]) { + await image.destroy({ transaction: t }) } + + actor[association] = [] } catch (err) { logger.error('Cannot remove old image of actor %s.', actor.url, { err }) } @@ -66,29 +65,37 @@ async function deleteActorImageInstance (actor: MActorImages, type: ActorImageTy return actor } +async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType, t: Transaction) { + try { + await toDelete.destroy({ transaction: t }) + + const association = buildAssociationName(type) + actor[association] = actor[association].filter(image => image.id !== toDelete.id) + } catch (err) { + logger.error('Cannot remove old actor image of actor %s.', actor.url, { err }) + } +} + // --------------------------------------------------------------------------- export { ImageInfo, - updateActorImageInstance, - deleteActorImageInstance + updateActorImages, + deleteActorImages } // --------------------------------------------------------------------------- -function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) { - const id = imageModel - ? imageModel.id - : null - - if (type === ActorImageType.AVATAR) { - actorModel.avatarId = id - actorModel.Avatar = imageModel - } else { - actorModel.bannerId = id - actorModel.Banner = imageModel - } +function addActorImage (actor: MActorImages, type: ActorImageType, imageModel: MActorImage) { + const association = buildAssociationName(type) + if (!actor[association]) actor[association] = [] + + actor[association].push(imageModel) +} - return actorModel +function buildAssociationName (type: ActorImageType) { + return type === ActorImageType.AVATAR + ? 'Avatars' + : 'Banners' } diff --git a/server/lib/activitypub/actors/shared/creator.ts b/server/lib/activitypub/actors/shared/creator.ts index 999aed97d..500bc9912 100644 --- a/server/lib/activitypub/actors/shared/creator.ts +++ b/server/lib/activitypub/actors/shared/creator.ts @@ -6,8 +6,8 @@ import { ServerModel } from '@server/models/server/server' import { VideoChannelModel } from '@server/models/video/video-channel' import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models' import { ActivityPubActor, ActorImageType } from '@shared/models' -import { updateActorImageInstance } from '../image' -import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImageInfoFromObject } from './object-to-model-attributes' +import { updateActorImages } from '../image' +import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes' import { fetchActorFollowsCount } from './url-to-object' export class APActorCreator { @@ -27,11 +27,11 @@ export class APActorCreator { return sequelizeTypescript.transaction(async t => { const server = await this.setServer(actorInstance, t) - await this.setImageIfNeeded(actorInstance, ActorImageType.AVATAR, t) - await this.setImageIfNeeded(actorInstance, ActorImageType.BANNER, t) - const { actorCreated, created } = await this.saveActor(actorInstance, t) + await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t) + await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t) + await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t) if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance @@ -71,10 +71,10 @@ export class APActorCreator { } private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) { - const imageInfo = getImageInfoFromObject(this.actorObject, type) - if (!imageInfo) return + const imagesInfo = getImagesInfoFromObject(this.actorObject, type) + if (imagesInfo.length === 0) return - return updateActorImageInstance(actor as MActorImages, type, imageInfo, t) + return updateActorImages(actor as MActorImages, type, imagesInfo, t) } private async saveActor (actor: MActor, t: Transaction) { diff --git a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts index 23bc972e5..f6a78c457 100644 --- a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts +++ b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts @@ -4,7 +4,7 @@ import { ActorModel } from '@server/models/actor/actor' import { FilteredModelAttributes } from '@server/types' import { getLowercaseExtension } from '@shared/core-utils' import { buildUUID } from '@shared/extra-utils' -import { ActivityPubActor, ActorImageType } from '@shared/models' +import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models' function getActorAttributesFromObject ( actorObject: ActivityPubActor, @@ -30,33 +30,36 @@ function getActorAttributesFromObject ( } } -function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) { - const mimetypes = MIMETYPES.IMAGE - const icon = type === ActorImageType.AVATAR - ? actorObject.icon +function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) { + const iconsOrImages = type === ActorImageType.AVATAR + ? actorObject.icons || actorObject.icon : actorObject.image - if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined + return normalizeIconOrImage(iconsOrImages).map(iconOrImage => { + const mimetypes = MIMETYPES.IMAGE - let extension: string + if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined - if (icon.mediaType) { - extension = mimetypes.MIMETYPE_EXT[icon.mediaType] - } else { - const tmp = getLowercaseExtension(icon.url) + let extension: string - if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp - } + if (iconOrImage.mediaType) { + extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType] + } else { + const tmp = getLowercaseExtension(iconOrImage.url) - if (!extension) return undefined + if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp + } - return { - name: buildUUID() + extension, - fileUrl: icon.url, - height: icon.height, - width: icon.width, - type - } + if (!extension) return undefined + + return { + name: buildUUID() + extension, + fileUrl: iconOrImage.url, + height: iconOrImage.height, + width: iconOrImage.width, + type + } + }) } function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { @@ -65,6 +68,15 @@ function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { export { getActorAttributesFromObject, - getImageInfoFromObject, + getImagesInfoFromObject, getActorDisplayNameFromObject } + +// --------------------------------------------------------------------------- + +function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] { + if (Array.isArray(icon)) return icon + if (icon) return [ icon ] + + return [] +} diff --git a/server/lib/activitypub/actors/updater.ts b/server/lib/activitypub/actors/updater.ts index 042438d9c..fe94af9f1 100644 --- a/server/lib/activitypub/actors/updater.ts +++ b/server/lib/activitypub/actors/updater.ts @@ -5,9 +5,9 @@ import { VideoChannelModel } from '@server/models/video/video-channel' import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models' import { ActivityPubActor, ActorImageType } from '@shared/models' import { getOrCreateAPOwner } from './get' -import { updateActorImageInstance } from './image' +import { updateActorImages } from './image' import { fetchActorFollowsCount } from './shared' -import { getImageInfoFromObject } from './shared/object-to-model-attributes' +import { getImagesInfoFromObject } from './shared/object-to-model-attributes' export class APActorUpdater { @@ -29,8 +29,8 @@ export class APActorUpdater { } async update () { - const avatarInfo = getImageInfoFromObject(this.actorObject, ActorImageType.AVATAR) - const bannerInfo = getImageInfoFromObject(this.actorObject, ActorImageType.BANNER) + const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR) + const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER) try { await this.updateActorInstance(this.actor, this.actorObject) @@ -47,8 +47,8 @@ export class APActorUpdater { } await runInReadCommittedTransaction(async t => { - await updateActorImageInstance(this.actor, ActorImageType.AVATAR, avatarInfo, t) - await updateActorImageInstance(this.actor, ActorImageType.BANNER, bannerInfo, t) + await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t) + await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t) }) await runInReadCommittedTransaction(async t => { diff --git a/server/lib/actor-image.ts b/server/lib/actor-image.ts new file mode 100644 index 000000000..e9bd148f6 --- /dev/null +++ b/server/lib/actor-image.ts @@ -0,0 +1,14 @@ +import maxBy from 'lodash/maxBy' + +function getBiggestActorImage (images: T[]) { + const image = maxBy(images, 'width') + + // If width is null, maxBy won't return a value + if (!image) return images[0] + + return image +} + +export { + getBiggestActorImage +} diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 19354ab70..c010f3c44 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -3,6 +3,7 @@ import { readFile } from 'fs-extra' import { join } from 'path' import validator from 'validator' import { toCompleteUUID } from '@server/helpers/custom-validators/misc' +import { ActorImageModel } from '@server/models/actor/actor-image' import { root } from '@shared/core-utils' import { escapeHTML } from '@shared/core-utils/renderer' import { sha256 } from '@shared/extra-utils' @@ -16,7 +17,6 @@ import { mdToOneLinePlainText } from '../helpers/markdown' import { CONFIG } from '../initializers/config' import { ACCEPT_HEADERS, - ACTOR_IMAGES_SIZE, CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, FILES_CONTENT_HASH, @@ -29,6 +29,7 @@ import { VideoModel } from '../models/video/video' import { VideoChannelModel } from '../models/video/video-channel' import { VideoPlaylistModel } from '../models/video/video-playlist' import { MAccountActor, MChannelActor } from '../types/models' +import { getBiggestActorImage } from './actor-image' import { ServerConfigManager } from './server-config-manager' type Tags = { @@ -273,10 +274,11 @@ class ClientHtml { const siteName = CONFIG.INSTANCE.NAME const title = entity.getDisplayName() + const avatar = getBiggestActorImage(entity.Actor.Avatars) const image = { - url: entity.Actor.getAvatarUrl(), - width: ACTOR_IMAGES_SIZE.AVATARS.width, - height: ACTOR_IMAGES_SIZE.AVATARS.height + url: ActorImageModel.getImageUrl(avatar), + width: avatar?.width, + height: avatar?.height } const ogType = 'website' diff --git a/server/lib/local-actor.ts b/server/lib/local-actor.ts index c6826759b..01046d017 100644 --- a/server/lib/local-actor.ts +++ b/server/lib/local-actor.ts @@ -1,5 +1,5 @@ -import 'multer' import { queue } from 'async' +import { remove } from 'fs-extra' import LRUCache from 'lru-cache' import { join } from 'path' import { ActorModel } from '@server/models/actor/actor' @@ -13,7 +13,7 @@ import { CONFIG } from '../initializers/config' import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants' import { sequelizeTypescript } from '../initializers/database' import { MAccountDefault, MActor, MChannelDefault } from '../types/models' -import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actors' +import { deleteActorImages, updateActorImages } from './activitypub/actors' import { sendUpdateActor } from './activitypub/send' function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) { @@ -33,64 +33,69 @@ function buildActorInstance (type: ActivityPubActorType, url: string, preferredU }) as MActor } -async function updateLocalActorImageFile ( +async function updateLocalActorImageFiles ( accountOrChannel: MAccountDefault | MChannelDefault, imagePhysicalFile: Express.Multer.File, type: ActorImageType ) { - const imageSize = type === ActorImageType.AVATAR - ? ACTOR_IMAGES_SIZE.AVATARS - : ACTOR_IMAGES_SIZE.BANNERS - - const extension = getLowercaseExtension(imagePhysicalFile.filename) - - const imageName = buildUUID() + extension - const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName) - await processImage(imagePhysicalFile.path, destination, imageSize) - - return retryTransactionWrapper(() => { - return sequelizeTypescript.transaction(async t => { - const actorImageInfo = { - name: imageName, - fileUrl: null, - height: imageSize.height, - width: imageSize.width, - onDisk: true - } - - const updatedActor = await updateActorImageInstance(accountOrChannel.Actor, type, actorImageInfo, t) - await updatedActor.save({ transaction: t }) - - await sendUpdateActor(accountOrChannel, t) - - return type === ActorImageType.AVATAR - ? updatedActor.Avatar - : updatedActor.Banner - }) - }) + const processImageSize = async (imageSize: { width: number, height: number }) => { + const extension = getLowercaseExtension(imagePhysicalFile.filename) + + const imageName = buildUUID() + extension + const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName) + await processImage(imagePhysicalFile.path, destination, imageSize, true) + + return { + imageName, + imageSize + } + } + + const processedImages = await Promise.all(ACTOR_IMAGES_SIZE[type].map(processImageSize)) + await remove(imagePhysicalFile.path) + + return retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => { + const actorImagesInfo = processedImages.map(({ imageName, imageSize }) => ({ + name: imageName, + fileUrl: null, + height: imageSize.height, + width: imageSize.width, + onDisk: true + })) + + const updatedActor = await updateActorImages(accountOrChannel.Actor, type, actorImagesInfo, t) + await updatedActor.save({ transaction: t }) + + await sendUpdateActor(accountOrChannel, t) + + return type === ActorImageType.AVATAR + ? updatedActor.Avatars + : updatedActor.Banners + })) } async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) { return retryTransactionWrapper(() => { return sequelizeTypescript.transaction(async t => { - const updatedActor = await deleteActorImageInstance(accountOrChannel.Actor, type, t) + const updatedActor = await deleteActorImages(accountOrChannel.Actor, type, t) await updatedActor.save({ transaction: t }) await sendUpdateActor(accountOrChannel, t) - return updatedActor.Avatar + return updatedActor.Avatars }) }) } -type DownloadImageQueueTask = { fileUrl: string, filename: string, type: ActorImageType } +type DownloadImageQueueTask = { + fileUrl: string + filename: string + type: ActorImageType + size: typeof ACTOR_IMAGES_SIZE[ActorImageType][0] +} const downloadImageQueue = queue((task, cb) => { - const size = task.type === ActorImageType.AVATAR - ? ACTOR_IMAGES_SIZE.AVATARS - : ACTOR_IMAGES_SIZE.BANNERS - - downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, size) + downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, task.size) .then(() => cb()) .catch(err => cb(err)) }, QUEUE_CONCURRENCY.ACTOR_PROCESS_IMAGE) @@ -110,7 +115,7 @@ const actorImagePathUnsafeCache = new LRUCache({ max: LRU_CACHE. export { actorImagePathUnsafeCache, - updateLocalActorImageFile, + updateLocalActorImageFiles, deleteLocalActorImageFile, pushActorImageProcessInQueue, buildActorInstance diff --git a/server/lib/notifier/shared/comment/comment-mention.ts b/server/lib/notifier/shared/comment/comment-mention.ts index 765cbaad9..ecd1687b4 100644 --- a/server/lib/notifier/shared/comment/comment-mention.ts +++ b/server/lib/notifier/shared/comment/comment-mention.ts @@ -77,7 +77,7 @@ export class CommentMention extends AbstractNotification