Verified Commit 557b13ae authored by Chocobozzz's avatar Chocobozzz
Browse files

Lazy load avatars

parent c5407d70
......@@ -97,6 +97,7 @@ import {
clientsRouter,
feedsRouter,
staticRouter,
lazyStaticRouter,
servicesRouter,
pluginsRouter,
webfingerRouter,
......@@ -192,6 +193,7 @@ app.use('/', botsRouter)
// Static files
app.use('/', staticRouter)
app.use('/', lazyStaticRouter)
// Client files, last valid routes!
if (cli.client) app.use('/', clientsRouter)
......
......@@ -4,6 +4,7 @@ export * from './client'
export * from './feeds'
export * from './services'
export * from './static'
export * from './lazy-static'
export * from './webfinger'
export * from './tracker'
export * from './bots'
......
import * as cors from 'cors'
import * as express from 'express'
import { LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants'
import { VideosCaptionCache, VideosPreviewCache } from '../lib/files-cache'
import { asyncMiddleware } from '../middlewares'
import { AvatarModel } from '../models/avatar/avatar'
import { logger } from '../helpers/logger'
import { avatarPathUnsafeCache, pushAvatarProcessInQueue } from '../lib/avatar'
const lazyStaticRouter = express.Router()
lazyStaticRouter.use(cors())
lazyStaticRouter.use(
LAZY_STATIC_PATHS.AVATARS + ':filename',
asyncMiddleware(getAvatar)
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.PREVIEWS + ':uuid.jpg',
asyncMiddleware(getPreview)
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
asyncMiddleware(getVideoCaption)
)
// ---------------------------------------------------------------------------
export {
lazyStaticRouter,
getPreview,
getVideoCaption
}
// ---------------------------------------------------------------------------
async function getAvatar (req: express.Request, res: express.Response) {
const filename = req.params.filename
if (avatarPathUnsafeCache.has(filename)) {
return res.sendFile(avatarPathUnsafeCache.get(filename), { maxAge: STATIC_MAX_AGE.SERVER })
}
const avatar = await AvatarModel.loadByName(filename)
if (avatar.onDisk === false) {
if (!avatar.fileUrl) return res.sendStatus(404)
logger.info('Lazy serve remote avatar image %s.', avatar.fileUrl)
await pushAvatarProcessInQueue({ filename: avatar.filename, fileUrl: avatar.fileUrl })
avatar.onDisk = true
avatar.save()
.catch(err => logger.error('Cannot save new avatar disk state.', { err }))
}
const path = avatar.getPath()
avatarPathUnsafeCache.set(filename, path)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER })
}
async function getPreview (req: express.Request, res: express.Response) {
const result = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
if (!result) return res.sendStatus(404)
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER })
}
async function getVideoCaption (req: express.Request, res: express.Response) {
const result = await VideosCaptionCache.Instance.getFilePath({
videoId: req.params.videoId,
language: req.params.captionLanguage
})
if (!result) return res.sendStatus(404)
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER })
}
......@@ -9,7 +9,6 @@ import {
STATIC_PATHS,
WEBSERVER
} from '../initializers/constants'
import { VideosCaptionCache, VideosPreviewCache } from '../lib/files-cache'
import { cacheRoute } from '../middlewares/cache'
import { asyncMiddleware, videosGetValidator } from '../middlewares'
import { VideoModel } from '../models/video/video'
......@@ -19,6 +18,7 @@ import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/node
import { join } from 'path'
import { root } from '../helpers/core-utils'
import { CONFIG } from '../initializers/config'
import { getPreview, getVideoCaption } from './lazy-static'
const staticRouter = express.Router()
......@@ -72,19 +72,20 @@ staticRouter.use(
express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE.SERVER, fallthrough: false }) // 404 if the file does not exist
)
// DEPRECATED: use lazy-static route instead
const avatarsPhysicalPath = CONFIG.STORAGE.AVATARS_DIR
staticRouter.use(
STATIC_PATHS.AVATARS,
express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE.SERVER, fallthrough: false }) // 404 if the file does not exist
)
// We don't have video previews, fetch them from the origin instance
// DEPRECATED: use lazy-static route instead
staticRouter.use(
STATIC_PATHS.PREVIEWS + ':uuid.jpg',
asyncMiddleware(getPreview)
)
// We don't have video captions, fetch them from the origin instance
// DEPRECATED: use lazy-static route instead
staticRouter.use(
STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
asyncMiddleware(getVideoCaption)
......@@ -177,23 +178,6 @@ export {
// ---------------------------------------------------------------------------
async function getPreview (req: express.Request, res: express.Response) {
const result = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
if (!result) return res.sendStatus(404)
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE })
}
async function getVideoCaption (req: express.Request, res: express.Response) {
const result = await VideosCaptionCache.Instance.getFilePath({
videoId: req.params.videoId,
language: req.params.captionLanguage
})
if (!result) return res.sendStatus(404)
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE })
}
async function generateNodeinfo (req: express.Request, res: express.Response) {
const { totalVideos } = await VideoModel.getStats()
const { totalLocalVideoComments } = await VideoCommentModel.getStats()
......
......@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 415
const LAST_MIGRATION_VERSION = 420
// ---------------------------------------------------------------------------
......@@ -498,6 +498,11 @@ const STATIC_DOWNLOAD_PATHS = {
TORRENTS: '/download/torrents/',
VIDEOS: '/download/videos/'
}
const LAZY_STATIC_PATHS = {
AVATARS: '/lazy-static/avatars/',
PREVIEWS: '/static/previews/',
VIDEO_CAPTIONS: '/static/video-captions/'
}
// Cache control
let STATIC_MAX_AGE = {
......@@ -536,9 +541,12 @@ const FILES_CACHE = {
}
}
const CACHE = {
const LRU_CACHE = {
USER_TOKENS: {
MAX_SIZE: 10000
MAX_SIZE: 1000
},
AVATAR_STATIC: {
MAX_SIZE: 500
}
}
......@@ -549,6 +557,10 @@ const MEMOIZE_TTL = {
OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours
}
const QUEUE_CONCURRENCY = {
AVATAR_PROCESS_IMAGE: 3
}
const REDUNDANCY = {
VIDEOS: {
RANDOMIZED_FACTOR: 5
......@@ -649,6 +661,7 @@ export {
WEBSERVER,
API_VERSION,
PEERTUBE_VERSION,
LAZY_STATIC_PATHS,
HLS_REDUNDANCY_DIRECTORY,
P2P_MEDIA_LOADER_PEER_VERSION,
AVATARS_SIZE,
......@@ -695,11 +708,12 @@ export {
VIDEO_PRIVACIES,
VIDEO_LICENCES,
VIDEO_STATES,
QUEUE_CONCURRENCY,
VIDEO_RATE_TYPES,
VIDEO_TRANSCODING_FPS,
FFMPEG_NICE,
VIDEO_ABUSE_STATES,
CACHE,
LRU_CACHE,
JOB_REQUEST_TIMEOUT,
USER_PASSWORD_RESET_LIFETIME,
MEMOIZE_TTL,
......
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction,
queryInterface: Sequelize.QueryInterface,
sequelize: Sequelize.Sequelize,
db: any
}): Promise<void> {
{
// We'll add a unique index on filename, so delete duplicates or PeerTube won't start
const query = 'DELETE FROM "avatar" s1 ' +
'USING (SELECT MIN(id) as id, filename FROM "avatar" GROUP BY "filename" HAVING COUNT(*) > 1) s2 ' +
'WHERE s1."filename" = s2."filename" AND s1.id <> s2.id'
await utils.sequelize.query(query)
}
{
const data = {
type: Sequelize.STRING,
allowNull: true,
defaultValue: null
}
await utils.queryInterface.addColumn('avatar', 'fileUrl', data)
}
{
const data = {
type: Sequelize.BOOLEAN,
allowNull: true,
defaultValue: null
}
await utils.queryInterface.addColumn('avatar', 'onDisk', data)
}
{
const query = 'UPDATE "avatar" SET "onDisk" = true;'
await utils.sequelize.query(query)
}
{
const data = {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: null
}
await utils.queryInterface.changeColumn('avatar', 'onDisk', data)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}
......@@ -10,9 +10,9 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
import { logger } from '../../helpers/logger'
import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
import { doRequest, downloadImage } from '../../helpers/requests'
import { doRequest } from '../../helpers/requests'
import { getUrlFromWebfinger } from '../../helpers/webfinger'
import { AVATARS_SIZE, MIMETYPES, WEBSERVER } from '../../initializers/constants'
import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
import { AccountModel } from '../../models/account/account'
import { ActorModel } from '../../models/activitypub/actor'
import { AvatarModel } from '../../models/avatar/avatar'
......@@ -21,7 +21,6 @@ import { VideoChannelModel } from '../../models/video/video-channel'
import { JobQueue } from '../job-queue'
import { getServerActor } from '../../helpers/utils'
import { ActorFetchByUrlType, fetchActorByUrl } from '../../helpers/actor'
import { CONFIG } from '../../initializers/config'
import { sequelizeTypescript } from '../../initializers/database'
// Set account keys, this could be long so process after the account creation and do not block the client
......@@ -141,25 +140,27 @@ async function updateActorInstance (actorInstance: ActorModel, attributes: Activ
actorInstance.followingUrl = attributes.following
}
async function updateActorAvatarInstance (actorInstance: ActorModel, avatarName: string, t: Transaction) {
if (avatarName !== undefined) {
if (actorInstance.avatarId) {
async function updateActorAvatarInstance (actor: ActorModel, info: { name: string, onDisk: boolean, fileUrl: string }, t: Transaction) {
if (info.name !== undefined) {
if (actor.avatarId) {
try {
await actorInstance.Avatar.destroy({ transaction: t })
await actor.Avatar.destroy({ transaction: t })
} catch (err) {
logger.error('Cannot remove old avatar of actor %s.', actorInstance.url, { err })
logger.error('Cannot remove old avatar of actor %s.', actor.url, { err })
}
}
const avatar = await AvatarModel.create({
filename: avatarName
filename: info.name,
onDisk: info.onDisk,
fileUrl: info.fileUrl
}, { transaction: t })
actorInstance.set('avatarId', avatar.id)
actorInstance.Avatar = avatar
actor.avatarId = avatar.id
actor.Avatar = avatar
}
return actorInstance
return actor
}
async function fetchActorTotalItems (url: string) {
......@@ -179,17 +180,17 @@ async function fetchActorTotalItems (url: string) {
}
}
async function fetchAvatarIfExists (actorJSON: ActivityPubActor) {
async function getAvatarInfoIfExists (actorJSON: ActivityPubActor) {
if (
actorJSON.icon && actorJSON.icon.type === 'Image' && MIMETYPES.IMAGE.MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined &&
isActivityPubUrlValid(actorJSON.icon.url)
) {
const extension = MIMETYPES.IMAGE.MIMETYPE_EXT[actorJSON.icon.mediaType]
const avatarName = uuidv4() + extension
await downloadImage(actorJSON.icon.url, CONFIG.STORAGE.AVATARS_DIR, avatarName, AVATARS_SIZE)
return avatarName
return {
name: uuidv4() + extension,
fileUrl: actorJSON.icon.url
}
}
return undefined
......@@ -245,8 +246,14 @@ async function refreshActorIfNeeded (
return sequelizeTypescript.transaction(async t => {
updateInstanceWithAnother(actor, result.actor)
if (result.avatarName !== undefined) {
await updateActorAvatarInstance(actor, result.avatarName, t)
if (result.avatar !== undefined) {
const avatarInfo = {
name: result.avatar.name,
fileUrl: result.avatar.fileUrl,
onDisk: false
}
await updateActorAvatarInstance(actor, avatarInfo, t)
}
// Force update
......@@ -279,7 +286,7 @@ export {
buildActorInstance,
setAsyncActorKeys,
fetchActorTotalItems,
fetchAvatarIfExists,
getAvatarInfoIfExists,
updateActorInstance,
refreshActorIfNeeded,
updateActorAvatarInstance,
......@@ -314,14 +321,17 @@ function saveActorAndServerAndModelIfNotExist (
const [ server ] = await ServerModel.findOrCreate(serverOptions)
// Save our new account in database
actor.set('serverId', server.id)
actor.serverId = server.id
// Avatar?
if (result.avatarName) {
if (result.avatar) {
const avatar = await AvatarModel.create({
filename: result.avatarName
filename: result.avatar.name,
fileUrl: result.avatar.fileUrl,
onDisk: false
}, { transaction: t })
actor.set('avatarId', avatar.id)
actor.avatarId = avatar.id
}
// Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists
......@@ -355,7 +365,10 @@ type FetchRemoteActorResult = {
summary: string
support?: string
playlists?: string
avatarName?: string
avatar?: {
name: string,
fileUrl: string
}
attributedTo: ActivityPubAttributedTo[]
}
async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: number, result: FetchRemoteActorResult }> {
......@@ -399,7 +412,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
followingUrl: actorJSON.following
})
const avatarName = await fetchAvatarIfExists(actorJSON)
const avatarInfo = await getAvatarInfoIfExists(actorJSON)
const name = actorJSON.name || actorJSON.preferredUsername
return {
......@@ -407,7 +420,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
result: {
actor,
name,
avatarName,
avatar: avatarInfo,
summary: actorJSON.summary,
support: actorJSON.support,
playlists: actorJSON.playlists,
......
......@@ -6,7 +6,7 @@ import { sequelizeTypescript } from '../../../initializers'
import { AccountModel } from '../../../models/account/account'
import { ActorModel } from '../../../models/activitypub/actor'
import { VideoChannelModel } from '../../../models/video/video-channel'
import { fetchAvatarIfExists, updateActorAvatarInstance, updateActorInstance } from '../actor'
import { getAvatarInfoIfExists, updateActorAvatarInstance, updateActorInstance } from '../actor'
import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
......@@ -105,7 +105,7 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
let accountOrChannelFieldsSave: object
// Fetch icon?
const avatarName = await fetchAvatarIfExists(actorAttributesToUpdate)
const avatarInfo = await getAvatarInfoIfExists(actorAttributesToUpdate)
try {
await sequelizeTypescript.transaction(async t => {
......@@ -118,8 +118,10 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
await updateActorInstance(actor, actorAttributesToUpdate)
if (avatarName !== undefined) {
await updateActorAvatarInstance(actor, avatarName, t)
if (avatarInfo !== undefined) {
const avatarOptions = Object.assign({}, avatarInfo, { onDisk: false })
await updateActorAvatarInstance(actor, avatarOptions, t)
}
await actor.save({ transaction: t })
......
import 'multer'
import { sendUpdateActor } from './activitypub/send'
import { AVATARS_SIZE } from '../initializers/constants'
import { AVATARS_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants'
import { updateActorAvatarInstance } from './activitypub'
import { processImage } from '../helpers/image-utils'
import { AccountModel } from '../models/account/account'
......@@ -10,6 +10,9 @@ import { retryTransactionWrapper } from '../helpers/database-utils'
import * as uuidv4 from 'uuid/v4'
import { CONFIG } from '../initializers/config'
import { sequelizeTypescript } from '../initializers/database'
import * as LRUCache from 'lru-cache'
import { queue } from 'async'
import { downloadImage } from '../helpers/requests'
async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, accountOrChannel: AccountModel | VideoChannelModel) {
const extension = extname(avatarPhysicalFile.filename)
......@@ -19,7 +22,13 @@ async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, a
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const updatedActor = await updateActorAvatarInstance(accountOrChannel.Actor, avatarName, t)
const avatarInfo = {
name: avatarName,
fileUrl: null,
onDisk: true
}
const updatedActor = await updateActorAvatarInstance(accountOrChannel.Actor, avatarInfo, t)
await updatedActor.save({ transaction: t })
await sendUpdateActor(accountOrChannel, t)
......@@ -29,6 +38,29 @@ async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, a
})
}
type DownloadImageQueueTask = { fileUrl: string, filename: string }
const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => {
downloadImage(task.fileUrl, CONFIG.STORAGE.AVATARS_DIR, task.filename, AVATARS_SIZE)
.then(() => cb())
.catch(err => cb(err))
}, QUEUE_CONCURRENCY.AVATAR_PROCESS_IMAGE)
function pushAvatarProcessInQueue (task: DownloadImageQueueTask) {
return new Promise((res, rej) => {
downloadImageQueue.push(task, err => {
if (err) return rej(err)
return res()
})
})
}
// Unsafe so could returns paths that does not exist anymore
const avatarPathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.AVATAR_STATIC.MAX_SIZE })
export {
updateActorAvatarFile
avatarPathUnsafeCache,
updateActorAvatarFile,
pushAvatarProcessInQueue
}
......@@ -4,13 +4,15 @@ import { logger } from '../helpers/logger'
import { UserModel } from '../models/account/user'
import { OAuthClientModel } from '../models/oauth/oauth-client'
import { OAuthTokenModel } from '../models/oauth/oauth-token'
import { CACHE } from '../initializers/constants'
import { LRU_CACHE } from '../initializers/constants'
import { Transaction } from 'sequelize'
import { CONFIG } from '../initializers/config'
import * as LRUCache from 'lru-cache'
type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date }
let accessTokenCache: { [ accessToken: string ]: OAuthTokenModel } = {}
let userHavingToken: { [ userId: number ]: string } = {}
const accessTokenCache = new LRUCache<string, OAuthTokenModel>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
const userHavingToken = new LRUCache<number, string>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
// ---------------------------------------------------------------------------
......@@ -21,18 +23,20 @@ function deleteUserToken (userId: number, t?: Transaction) {
}
function clearCacheByUserId (userId: number) {
const token = userHavingToken[userId]
const token = userHavingToken.get(userId)
if (token !== undefined) {
accessTokenCache[ token ] = undefined
userHavingToken[ userId ] = undefined
accessTokenCache.del(token)
userHavingToken.del(userId)
}
}
function clearCacheByToken (token: string) {
const tokenModel = accessTokenCache[ token ]
const tokenModel = accessTokenCache.get(token)
if (tokenModel !== undefined) {
userHavingToken[tokenModel.userId] = undefined