From c5911fd347c76e8bdc05ea9f3ee9efed4a58c236 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 29 Dec 2017 19:10:13 +0100 Subject: Begin to add avatar to actors --- server/controllers/activitypub/client.ts | 6 +- server/controllers/api/users.ts | 53 ++++++++++++- server/controllers/api/videos/index.ts | 27 +------ server/controllers/static.ts | 6 ++ server/helpers/custom-validators/users.ts | 21 ++++- server/helpers/utils.ts | 30 ++++++- server/initializers/constants.ts | 19 ++++- .../initializers/migrations/0150-avatar-cascade.ts | 28 +++++++ server/lib/activitypub/actor.ts | 87 ++++++++++++++------- server/lib/activitypub/url.ts | 2 +- server/middlewares/validators/users.ts | 22 +++++- server/models/account/account.ts | 6 +- server/models/account/user.ts | 8 +- server/models/activitypub/actor.ts | 37 +++++++-- server/models/avatar/avatar.ts | 30 ++++++- server/models/video/video-comment.ts | 2 +- server/tests/api/check-params/users.ts | 22 +++++- server/tests/api/fixtures/avatar.png | Bin 0 -> 1674 bytes server/tests/api/users/users.ts | 18 ++++- server/tests/utils/users/users.ts | 29 ++++++- server/tests/utils/videos/videos.ts | 4 +- 21 files changed, 366 insertions(+), 91 deletions(-) create mode 100644 server/initializers/migrations/0150-avatar-cascade.ts create mode 100644 server/tests/api/fixtures/avatar.png (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 71e706346..e0ab3188b 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -16,17 +16,17 @@ import { VideoShareModel } from '../../models/video/video-share' const activityPubClientRouter = express.Router() -activityPubClientRouter.get('/account/:name', +activityPubClientRouter.get('/accounts/:name', executeIfActivityPub(asyncMiddleware(localAccountValidator)), executeIfActivityPub(accountController) ) -activityPubClientRouter.get('/account/:name/followers', +activityPubClientRouter.get('/accounts/:name/followers', executeIfActivityPub(asyncMiddleware(localAccountValidator)), executeIfActivityPub(asyncMiddleware(accountFollowersController)) ) -activityPubClientRouter.get('/account/:name/following', +activityPubClientRouter.get('/accounts/:name/following', executeIfActivityPub(asyncMiddleware(localAccountValidator)), executeIfActivityPub(asyncMiddleware(accountFollowingController)) ) diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 75393ad17..57b98b84a 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts @@ -1,20 +1,26 @@ import * as express from 'express' +import { extname, join } from 'path' +import * as uuidv4 from 'uuid/v4' import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../shared' +import { renamePromise } from '../../helpers/core-utils' import { retryTransactionWrapper } from '../../helpers/database-utils' import { logger } from '../../helpers/logger' -import { getFormattedObjects } from '../../helpers/utils' -import { CONFIG } from '../../initializers' +import { createReqFiles, getFormattedObjects } from '../../helpers/utils' +import { AVATAR_MIMETYPE_EXT, CONFIG, sequelizeTypescript } from '../../initializers' import { createUserAccountAndChannel } from '../../lib/user' import { asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setPagination, setUsersSort, setVideosSort, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator } from '../../middlewares' -import { videosSortValidator } from '../../middlewares/validators' +import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators' import { AccountVideoRateModel } from '../../models/account/account-video-rate' import { UserModel } from '../../models/account/user' +import { AvatarModel } from '../../models/avatar/avatar' import { VideoModel } from '../../models/video/video' +const reqAvatarFile = createReqFiles('avatarfile', CONFIG.STORAGE.AVATARS_DIR, AVATAR_MIMETYPE_EXT) + const usersRouter = express.Router() usersRouter.get('/me', @@ -71,6 +77,13 @@ usersRouter.put('/me', asyncMiddleware(updateMe) ) +usersRouter.post('/me/avatar/pick', + authenticate, + reqAvatarFile, + usersUpdateMyAvatarValidator, + asyncMiddleware(updateMyAvatar) +) + usersRouter.put('/:id', authenticate, ensureUserHasRight(UserRight.MANAGE_USERS), @@ -216,6 +229,40 @@ async function updateMe (req: express.Request, res: express.Response, next: expr return res.sendStatus(204) } +async function updateMyAvatar (req: express.Request, res: express.Response, next: express.NextFunction) { + const avatarPhysicalFile = req.files['avatarfile'][0] + const actor = res.locals.oauth.token.user.Account.Actor + + const avatarDir = CONFIG.STORAGE.AVATARS_DIR + const source = join(avatarDir, avatarPhysicalFile.filename) + const extension = extname(avatarPhysicalFile.filename) + const avatarName = uuidv4() + extension + const destination = join(avatarDir, avatarName) + + await renamePromise(source, destination) + + const { avatar } = await sequelizeTypescript.transaction(async t => { + const avatar = await AvatarModel.create({ + filename: avatarName + }, { transaction: t }) + + if (actor.Avatar) { + await actor.Avatar.destroy({ transaction: t }) + } + + actor.set('avatarId', avatar.id) + await actor.save({ transaction: t }) + + return { actor, avatar } + }) + + return res + .json({ + avatar: avatar.toFormattedJSON() + }) + .end() +} + async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) { const body: UserUpdate = req.body const user = res.locals.user as UserModel diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 11e3da5cc..ff0d967e1 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -6,7 +6,7 @@ import { renamePromise } from '../../../helpers/core-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils' import { getVideoFileHeight } from '../../../helpers/ffmpeg-utils' import { logger } from '../../../helpers/logger' -import { generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils' +import { createReqFiles, generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils' import { CONFIG, sequelizeTypescript, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, VIDEO_PRIVACIES @@ -29,28 +29,7 @@ import { rateVideoRouter } from './rate' const videosRouter = express.Router() -// multer configuration -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, CONFIG.STORAGE.VIDEOS_DIR) - }, - - filename: async (req, file, cb) => { - const extension = VIDEO_MIMETYPE_EXT[file.mimetype] - let randomString = '' - - try { - randomString = await generateRandomString(16) - } catch (err) { - logger.error('Cannot generate random string for file name.', err) - randomString = 'fake-random-string' - } - - cb(null, randomString + extension) - } -}) - -const reqFiles = multer({ storage: storage }).fields([{ name: 'videofile', maxCount: 1 }]) +const reqVideoFile = createReqFiles('videofile', CONFIG.STORAGE.VIDEOS_DIR, VIDEO_MIMETYPE_EXT) videosRouter.use('/', abuseVideoRouter) videosRouter.use('/', blacklistRouter) @@ -85,7 +64,7 @@ videosRouter.put('/:id', ) videosRouter.post('/upload', authenticate, - reqFiles, + reqVideoFile, asyncMiddleware(videosAddValidator), asyncMiddleware(addVideoRetryWrapper) ) diff --git a/server/controllers/static.ts b/server/controllers/static.ts index ccae60517..eece9c06b 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -32,6 +32,12 @@ staticRouter.use( express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE }) ) +const avatarsPhysicalPath = CONFIG.STORAGE.AVATARS_DIR +staticRouter.use( + STATIC_PATHS.AVATARS, + express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE }) +) + // Video previews path for express staticRouter.use( STATIC_PATHS.PREVIEWS + ':uuid.jpg', diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts index 159c2a700..6ed60c1c4 100644 --- a/server/helpers/custom-validators/users.ts +++ b/server/helpers/custom-validators/users.ts @@ -1,7 +1,7 @@ import * as validator from 'validator' import 'express-validator' -import { exists } from './misc' +import { exists, isArray } from './misc' import { CONSTRAINTS_FIELDS } from '../../initializers' import { UserRole } from '../../../shared' @@ -37,6 +37,22 @@ function isUserRoleValid (value: any) { return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined } +function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { + // Should have files + if (!files) return false + if (isArray(files)) return false + + // Should have videofile file + const avatarfile = files['avatarfile'] + if (!avatarfile || avatarfile.length === 0) return false + + // The file should exist + const file = avatarfile[0] + if (!file || !file.originalname) return false + + return new RegExp('^image/(png|jpeg)$', 'i').test(file.mimetype) +} + // --------------------------------------------------------------------------- export { @@ -45,5 +61,6 @@ export { isUserVideoQuotaValid, isUserUsernameValid, isUserDisplayNSFWValid, - isUserAutoPlayVideoValid + isUserAutoPlayVideoValid, + isAvatarFile } diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index 769aa83c6..7a32e286c 100644 --- a/server/helpers/utils.ts +++ b/server/helpers/utils.ts @@ -1,8 +1,9 @@ import * as express from 'express' +import * as multer from 'multer' import { Model } from 'sequelize-typescript' import { ResultList } from '../../shared' import { VideoResolution } from '../../shared/models/videos' -import { CONFIG, REMOTE_SCHEME } from '../initializers' +import { CONFIG, REMOTE_SCHEME, VIDEO_MIMETYPE_EXT } from '../initializers' import { UserModel } from '../models/account/user' import { ActorModel } from '../models/activitypub/actor' import { ApplicationModel } from '../models/application/application' @@ -26,6 +27,30 @@ function badRequest (req: express.Request, res: express.Response, next: express. return res.type('json').status(400).end() } +function createReqFiles (fieldName: string, storageDir: string, mimeTypes: { [ id: string ]: string }) { + const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, storageDir) + }, + + filename: async (req, file, cb) => { + const extension = mimeTypes[file.mimetype] + let randomString = '' + + try { + randomString = await generateRandomString(16) + } catch (err) { + logger.error('Cannot generate random string for file name.', err) + randomString = 'fake-random-string' + } + + cb(null, randomString + extension) + } + }) + + return multer({ storage }).fields([{ name: fieldName, maxCount: 1 }]) +} + async function generateRandomString (size: number) { const raw = await pseudoRandomBytesPromise(size) @@ -122,5 +147,6 @@ export { resetSequelizeInstance, getServerActor, SortType, - getHostWithPort + getHostWithPort, + createReqFiles } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 3a5a557d4..50a29dc43 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -9,7 +9,7 @@ import { isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 145 +const LAST_MIGRATION_VERSION = 150 // --------------------------------------------------------------------------- @@ -172,7 +172,10 @@ const CONSTRAINTS_FIELDS = { ACTOR: { PUBLIC_KEY: { min: 10, max: 5000 }, // Length PRIVATE_KEY: { min: 10, max: 5000 }, // Length - URL: { min: 3, max: 2000 } // Length + URL: { min: 3, max: 2000 }, // Length + AVATAR: { + EXTNAME: [ '.png', '.jpeg', '.jpg' ] + } }, VIDEO_EVENTS: { COUNT: { min: 0 } @@ -250,6 +253,12 @@ const VIDEO_MIMETYPE_EXT = { 'video/mp4': '.mp4' } +const AVATAR_MIMETYPE_EXT = { + 'image/png': '.png', + 'image/jpg': '.jpg', + 'image/jpeg': '.jpg' +} + // --------------------------------------------------------------------------- const SERVER_ACTOR_NAME = 'peertube' @@ -291,7 +300,8 @@ const STATIC_PATHS = { PREVIEWS: '/static/previews/', THUMBNAILS: '/static/thumbnails/', TORRENTS: '/static/torrents/', - WEBSEED: '/static/webseed/' + WEBSEED: '/static/webseed/', + AVATARS: '/static/avatars/' } // Cache control @@ -376,5 +386,6 @@ export { VIDEO_PRIVACIES, VIDEO_LICENCES, VIDEO_RATE_TYPES, - VIDEO_MIMETYPE_EXT + VIDEO_MIMETYPE_EXT, + AVATAR_MIMETYPE_EXT } diff --git a/server/initializers/migrations/0150-avatar-cascade.ts b/server/initializers/migrations/0150-avatar-cascade.ts new file mode 100644 index 000000000..821696717 --- /dev/null +++ b/server/initializers/migrations/0150-avatar-cascade.ts @@ -0,0 +1,28 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction, + queryInterface: Sequelize.QueryInterface, + sequelize: Sequelize.Sequelize +}): Promise { + await utils.queryInterface.removeConstraint('actor', 'actor_avatarId_fkey') + + await utils.queryInterface.addConstraint('actor', [ 'avatarId' ], { + type: 'foreign key', + references: { + table: 'avatar', + field: 'id' + }, + onDelete: 'set null', + onUpdate: 'CASCADE' + }) +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index e590dc72d..e557896e8 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts @@ -1,16 +1,20 @@ import * as Bluebird from 'bluebird' +import { join } from 'path' import { Transaction } from 'sequelize' import * as url from 'url' +import * as uuidv4 from 'uuid/v4' import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' import { isRemoteActorValid } from '../../helpers/custom-validators/activitypub/actor' +import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { retryTransactionWrapper } from '../../helpers/database-utils' import { logger } from '../../helpers/logger' import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto' -import { doRequest } from '../../helpers/requests' +import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' import { CONFIG, sequelizeTypescript } from '../../initializers' import { AccountModel } from '../../models/account/account' import { ActorModel } from '../../models/activitypub/actor' +import { AvatarModel } from '../../models/avatar/avatar' import { ServerModel } from '../../models/server/server' import { VideoChannelModel } from '../../models/video/video-channel' @@ -62,6 +66,32 @@ async function getOrCreateActorAndServerAndModel (actorUrl: string, recurseIfNee return actor } +function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) { + return new ActorModel({ + type, + url, + preferredUsername, + uuid, + publicKey: null, + privateKey: null, + followersCount: 0, + followingCount: 0, + inboxUrl: url + '/inbox', + outboxUrl: url + '/outbox', + sharedInboxUrl: CONFIG.WEBSERVER.URL + '/inbox', + followersUrl: url + '/followers', + followingUrl: url + '/following' + }) +} + +export { + getOrCreateActorAndServerAndModel, + buildActorInstance, + setAsyncActorKeys +} + +// --------------------------------------------------------------------------- + function saveActorAndServerAndModelIfNotExist ( result: FetchRemoteActorResult, ownerActor?: ActorModel, @@ -90,6 +120,14 @@ function saveActorAndServerAndModelIfNotExist ( // Save our new account in database actor.set('serverId', server.id) + // Avatar? + if (result.avatarName) { + const avatar = await AvatarModel.create({ + filename: result.avatarName + }, { transaction: t }) + actor.set('avatarId', avatar.id) + } + // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists // (which could be false in a retried query) const actorCreated = await ActorModel.create(actor.toJSON(), { transaction: t }) @@ -112,6 +150,7 @@ type FetchRemoteActorResult = { actor: ActorModel name: string summary: string + avatarName?: string attributedTo: ActivityPubAttributedTo[] } async function fetchRemoteActor (actorUrl: string): Promise { @@ -151,43 +190,33 @@ async function fetchRemoteActor (actorUrl: string): Promise isAvatarFile(req.files)).withMessage( + 'This file is not supported. Please, make sure it is of the following type : ' + + CONSTRAINTS_FIELDS.ACTOR.AVATAR.EXTNAME.join(', ') + ), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking usersUpdateMyAvatarValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + const usersGetValidator = [ param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), @@ -145,7 +162,8 @@ export { usersUpdateMeValidator, usersVideoRatingValidator, ensureUserRegistrationAllowed, - usersGetValidator + usersGetValidator, + usersUpdateMyAvatarValidator } // --------------------------------------------------------------------------- diff --git a/server/models/account/account.ts b/server/models/account/account.ts index 1ee232537..d3503aaa3 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts @@ -13,6 +13,7 @@ import { Table, UpdatedAt } from 'sequelize-typescript' +import { Account } from '../../../shared/models/actors' import { isUserUsernameValid } from '../../helpers/custom-validators/users' import { sendDeleteActor } from '../../lib/activitypub/send' import { ActorModel } from '../activitypub/actor' @@ -165,11 +166,12 @@ export class AccountModel extends Model { return AccountModel.findOne(query) } - toFormattedJSON () { + toFormattedJSON (): Account { const actor = this.Actor.toFormattedJSON() const account = { id: this.id, - name: this.name, + name: this.Actor.preferredUsername, + displayName: this.name, createdAt: this.createdAt, updatedAt: this.updatedAt } diff --git a/server/models/account/user.ts b/server/models/account/user.ts index d7e09e328..4226bcb35 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -4,6 +4,7 @@ import { Scopes, Table, UpdatedAt } from 'sequelize-typescript' import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' +import { User } from '../../../shared/models/users' import { isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, isUserVideoQuotaValid @@ -210,7 +211,7 @@ export class UserModel extends Model { return comparePassword(password, this.password) } - toFormattedJSON () { + toFormattedJSON (): User { const json = { id: this.id, username: this.username, @@ -221,11 +222,12 @@ export class UserModel extends Model { roleLabel: USER_ROLE_LABELS[ this.role ], videoQuota: this.videoQuota, createdAt: this.createdAt, - account: this.Account.toFormattedJSON() + account: this.Account.toFormattedJSON(), + videoChannels: [] } if (Array.isArray(this.Account.VideoChannels) === true) { - json['videoChannels'] = this.Account.VideoChannels + json.videoChannels = this.Account.VideoChannels .map(c => c.toFormattedJSON()) .sort((v1, v2) => { if (v1.createdAt < v2.createdAt) return -1 diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 3d96b3706..8422653df 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -1,5 +1,5 @@ import { values } from 'lodash' -import { join } from 'path' +import { extname, join } from 'path' import * as Sequelize from 'sequelize' import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, DefaultScope, ForeignKey, HasMany, HasOne, Is, IsUUID, Model, Scopes, @@ -30,6 +30,10 @@ enum ScopeNames { { model: () => ServerModel, required: false + }, + { + model: () => AvatarModel, + required: false } ] }) @@ -47,6 +51,10 @@ enum ScopeNames { { model: () => ServerModel, required: false + }, + { + model: () => AvatarModel, + required: false } ] } @@ -141,7 +149,7 @@ export class ActorModel extends Model { foreignKey: { allowNull: true }, - onDelete: 'cascade' + onDelete: 'set null' }) Avatar: AvatarModel @@ -253,11 +261,7 @@ export class ActorModel extends Model { 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 - } + avatar = this.Avatar.toFormattedJSON() } let score: number @@ -286,6 +290,16 @@ export class ActorModel extends Model { activityPubType = 'Group' as 'Group' } + let icon = undefined + if (this.avatarId) { + const extension = extname(this.Avatar.filename) + icon = { + type: 'Image', + mediaType: extension === '.png' ? 'image/png' : 'image/jpeg', + url: this.getAvatarUrl() + } + } + const json = { type: activityPubType, id: this.url, @@ -304,7 +318,8 @@ export class ActorModel extends Model { id: this.getPublicKeyUrl(), owner: this.url, publicKeyPem: this.publicKey - } + }, + icon } return activityPubContextify(json) @@ -353,4 +368,10 @@ export class ActorModel extends Model { getHost () { return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST } + + getAvatarUrl () { + if (!this.avatarId) return undefined + + return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath + } } diff --git a/server/models/avatar/avatar.ts b/server/models/avatar/avatar.ts index 2e7a8ae2c..7493c3d75 100644 --- a/server/models/avatar/avatar.ts +++ b/server/models/avatar/avatar.ts @@ -1,4 +1,10 @@ -import { AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { join } from 'path' +import { AfterDestroy, AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { Avatar } from '../../../shared/models/avatars/avatar.model' +import { unlinkPromise } from '../../helpers/core-utils' +import { logger } from '../../helpers/logger' +import { CONFIG, STATIC_PATHS } from '../../initializers' +import { sendDeleteVideo } from '../../lib/activitypub/send' @Table({ tableName: 'avatar' @@ -14,4 +20,26 @@ export class AvatarModel extends Model { @UpdatedAt updatedAt: Date + + @AfterDestroy + static removeFilesAndSendDelete (instance: AvatarModel) { + return instance.removeAvatar() + } + + toFormattedJSON (): Avatar { + return { + path: this.getWebserverPath(), + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } + + getWebserverPath () { + return join(STATIC_PATHS.AVATARS, this.filename) + } + + removeAvatar () { + const avatarPath = join(CONFIG.STORAGE.AVATARS_DIR, this.filename) + return unlinkPromise(avatarPath) + } } diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index d381ccafa..829022a51 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -214,7 +214,7 @@ export class VideoCommentModel extends Model { static listThreadCommentsForApi (videoId: number, threadId: number) { const query = { - order: [ [ 'id', 'ASC' ] ], + order: [ [ 'createdAt', 'DESC' ] ], where: { videoId, [ Sequelize.Op.or ]: [ diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts index 0c126dbff..44412ad82 100644 --- a/server/tests/api/check-params/users.ts +++ b/server/tests/api/check-params/users.ts @@ -2,11 +2,13 @@ import { omit } from 'lodash' import 'mocha' +import { join } from "path" import { UserRole } from '../../../../shared' import { createUser, flushTests, getMyUserInformation, getMyUserVideoRating, getUsersList, immutableAssign, killallServers, makeGetRequest, - makePostBodyRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers, updateUser, + makePostBodyRequest, makePostUploadRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers, + updateUser, uploadVideo, userLogin } from '../../utils' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' @@ -266,6 +268,24 @@ describe('Test users API validators', function () { }) }) + describe('When updating my avatar', function () { + it('Should fail without an incorrect input file', async function () { + const fields = {} + const attaches = { + 'avatarfile': join(__dirname, '..', 'fixtures', 'video_short.mp4') + } + await makePostUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) + }) + + it('Should succeed with the correct params', async function () { + const fields = {} + const attaches = { + 'avatarfile': join(__dirname, '..', 'fixtures', 'avatar.png') + } + await makePostUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) + }) + }) + describe('When updating a user', function () { before(async function () { diff --git a/server/tests/api/fixtures/avatar.png b/server/tests/api/fixtures/avatar.png new file mode 100644 index 000000000..4b7fd2c0a Binary files /dev/null and b/server/tests/api/fixtures/avatar.png differ diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 19549acdd..3390b2d56 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -6,7 +6,7 @@ import { UserRole } from '../../../../shared/index' import { createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoRating, getUserInformation, getUsersList, getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, registerUser, removeUser, removeVideo, - runServer, ServerInfo, serverLogin, updateMyUser, updateUser, uploadVideo + runServer, ServerInfo, serverLogin, testVideoImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo } from '../../utils/index' import { follow } from '../../utils/server/follows' import { setAccessTokensToServers } from '../../utils/users/login' @@ -340,6 +340,22 @@ describe('Test users', function () { expect(user.id).to.be.a('number') }) + it('Should be able to update my avatar', async function () { + const fixture = 'avatar.png' + + await updateMyAvatar({ + url: server.url, + accessToken: accessTokenUser, + fixture + }) + + const res = await getMyUserInformation(server.url, accessTokenUser) + const user = res.body + + const test = await testVideoImage(server.url, 'avatar', user.account.avatar.path, '.png') + expect(test).to.equal(true) + }) + it('Should be able to update another user', async function () { await updateUser({ url: server.url, diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts index e0cca3f51..90b1ca0a6 100644 --- a/server/tests/utils/users/users.ts +++ b/server/tests/utils/users/users.ts @@ -1,5 +1,6 @@ +import { isAbsolute, join } from 'path' import * as request from 'supertest' -import { makePutBodyRequest } from '../' +import { makePostUploadRequest, makePutBodyRequest } from '../' import { UserRole } from '../../../../shared/index' @@ -137,6 +138,29 @@ function updateMyUser (options: { }) } +function updateMyAvatar (options: { + url: string, + accessToken: string, + fixture: string +}) { + const path = '/api/v1/users/me/avatar/pick' + let filePath = '' + if (isAbsolute(options.fixture)) { + filePath = options.fixture + } else { + filePath = join(__dirname, '..', '..', 'api', 'fixtures', options.fixture) + } + + return makePostUploadRequest({ + url: options.url, + path, + token: options.accessToken, + fields: {}, + attaches: { avatarfile: filePath }, + statusCodeExpected: 200 + }) +} + function updateUser (options: { url: string userId: number, @@ -173,5 +197,6 @@ export { removeUser, updateUser, updateMyUser, - getUserInformation + getUserInformation, + updateMyAvatar } diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts index d6bf27dc7..aca51ee5d 100644 --- a/server/tests/utils/videos/videos.ts +++ b/server/tests/utils/videos/videos.ts @@ -201,7 +201,7 @@ function searchVideoWithSort (url: string, search: string, sort: string) { .expect('Content-Type', /json/) } -async function testVideoImage (url: string, imageName: string, imagePath: string) { +async function testVideoImage (url: string, imageName: string, imagePath: string, extension = '.jpg') { // Don't test images if the node env is not set // Because we need a special ffmpeg version for this test if (process.env['NODE_TEST_IMAGE']) { @@ -209,7 +209,7 @@ async function testVideoImage (url: string, imageName: string, imagePath: string .get(imagePath) .expect(200) - const data = await readFilePromise(join(__dirname, '..', '..', 'api', 'fixtures', imageName + '.jpg')) + const data = await readFilePromise(join(__dirname, '..', '..', 'api', 'fixtures', imageName + extension)) return data.equals(res.body) } else { -- cgit v1.2.3