diff --git a/.gitignore b/.gitignore index 62e252782..5d882360d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /test6/ /uploads/ /videos/ +/avatars/ /thumbnails/ /previews/ /certs/ diff --git a/client/src/app/account/account-settings/account-settings.component.html b/client/src/app/account/account-settings/account-settings.component.html index f14eadd49..fe345207a 100644 --- a/client/src/app/account/account-settings/account-settings.component.html +++ b/client/src/app/account/account-settings/account-settings.component.html @@ -1,5 +1,5 @@
- Avatar + Avatar
@@ -7,6 +7,10 @@
+
+ Change your avatar + +
Account settings
diff --git a/client/src/app/account/account-settings/account-settings.component.scss b/client/src/app/account/account-settings/account-settings.component.scss index 7f1ade377..accd65214 100644 --- a/client/src/app/account/account-settings/account-settings.component.scss +++ b/client/src/app/account/account-settings/account-settings.component.scss @@ -21,6 +21,12 @@ } } +.button-file { + @include peertube-button-file(auto); + + margin-top: 10px; +} + .account-title { text-transform: uppercase; color: $orange-color; diff --git a/client/src/app/account/account-settings/account-settings.component.ts b/client/src/app/account/account-settings/account-settings.component.ts index cba251000..3e03085ce 100644 --- a/client/src/app/account/account-settings/account-settings.component.ts +++ b/client/src/app/account/account-settings/account-settings.component.ts @@ -1,6 +1,10 @@ -import { Component, OnInit } from '@angular/core' +import { HttpEventType, HttpResponse } from '@angular/common/http' +import { Component, OnInit, ViewChild } from '@angular/core' +import { NotificationsService } from 'angular2-notifications' +import { VideoPrivacy } from '../../../../../shared/models/videos' import { User } from '../../shared' import { AuthService } from '../../core' +import { UserService } from '../../shared/users' @Component({ selector: 'my-account-settings', @@ -8,15 +12,39 @@ import { AuthService } from '../../core' styleUrls: [ './account-settings.component.scss' ] }) export class AccountSettingsComponent implements OnInit { + @ViewChild('avatarfileInput') avatarfileInput + user: User = null - constructor (private authService: AuthService) {} + constructor ( + private userService: UserService, + private authService: AuthService, + private notificationsService: NotificationsService + ) {} ngOnInit () { this.user = this.authService.getUser() } - getAvatarPath () { - return this.user.getAvatarPath() + getAvatarUrl () { + return this.user.getAvatarUrl() + } + + changeAvatar () { + const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ] + + const formData = new FormData() + formData.append('avatarfile', avatarfile) + + this.userService.changeAvatar(formData) + .subscribe( + data => { + this.notificationsService.success('Success', 'Avatar changed.') + + this.user.account.avatar = data.avatar + }, + + err => this.notificationsService.error('Error', err.message) + ) } } diff --git a/client/src/app/account/account-videos/account-videos.component.ts b/client/src/app/account/account-videos/account-videos.component.ts index 22941619d..d51b70e06 100644 --- a/client/src/app/account/account-videos/account-videos.component.ts +++ b/client/src/app/account/account-videos/account-videos.component.ts @@ -68,7 +68,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit .subscribe( res => this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`), - err => this.notificationsService.error('Error', err.text) + err => this.notificationsService.error('Error', err.message) ) } ) @@ -86,7 +86,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit this.spliceVideosById(video.id) }, - error => this.notificationsService.error('Error', error.text) + error => this.notificationsService.error('Error', error.message) ) } ) diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index c914848ae..8a2ba77d6 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -9,8 +9,8 @@ import 'rxjs/add/operator/mergeMap' import { Observable } from 'rxjs/Observable' import { ReplaySubject } from 'rxjs/ReplaySubject' import { Subject } from 'rxjs/Subject' -import { OAuthClientLocal, User as UserServerModel, UserRefreshToken, UserRole, VideoChannel } from '../../../../../shared' -import { Account } from '../../../../../shared/models/actors' +import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared' +import { User } from '../../../../../shared/models/users' import { UserLogin } from '../../../../../shared/models/users/user-login.model' import { environment } from '../../../environments/environment' import { RestExtractor } from '../../shared/rest' @@ -25,20 +25,7 @@ interface UserLoginWithUsername extends UserLogin { username: string } -interface UserLoginWithUserInformation extends UserLogin { - access_token: string - refresh_token: string - token_type: string - username: string - id: number - role: UserRole - displayNSFW: boolean - autoPlayVideo: boolean - email: string - videoQuota: number - account: Account - videoChannels: VideoChannel[] -} +type UserLoginWithUserInformation = UserLoginWithUsername & User @Injectable() export class AuthService { @@ -209,21 +196,7 @@ export class AuthService { const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) return this.http.get(AuthService.BASE_USER_INFORMATION_URL, { headers }) - .map(res => { - const newProperties = { - id: res.id, - role: res.role, - displayNSFW: res.displayNSFW, - autoPlayVideo: res.autoPlayVideo, - email: res.email, - videoQuota: res.videoQuota, - account: res.account, - videoChannels: res.videoChannels - } - - return Object.assign(obj, newProperties) - } - ) + .map(res => Object.assign(obj, res)) } private handleLogin (obj: UserLoginWithUserInformation) { diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index 6f52f4f45..5ea859fd2 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html @@ -1,6 +1,6 @@
- Avatar + Avatar
{{ user.username }} diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts index 8b8b714a8..1f66e3754 100644 --- a/client/src/app/menu/menu.component.ts +++ b/client/src/app/menu/menu.component.ts @@ -51,8 +51,8 @@ export class MenuComponent implements OnInit { ) } - getUserAvatarPath () { - return this.user.getAvatarPath() + getUserAvatarUrl () { + return this.user.getAvatarUrl() } isRegistrationAllowed () { diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts index bacaa208a..cc46dad77 100644 --- a/client/src/app/shared/account/account.model.ts +++ b/client/src/app/shared/account/account.model.ts @@ -1,11 +1,13 @@ import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' import { Avatar } from '../../../../../shared/models/avatars/avatar.model' import { environment } from '../../../environments/environment' +import { getAbsoluteAPIUrl } from '../misc/utils' export class Account implements ServerAccount { id: number uuid: string name: string + displayName: string host: string followingCount: number followersCount: number @@ -13,9 +15,11 @@ export class Account implements ServerAccount { updatedAt: Date avatar: Avatar - static GET_ACCOUNT_AVATAR_PATH (account: Account) { - if (account && account.avatar) return account.avatar.path + static GET_ACCOUNT_AVATAR_URL (account: Account) { + const absoluteAPIUrl = getAbsoluteAPIUrl() - return '/client/assets/images/default-avatar.png' + if (account && account.avatar) return absoluteAPIUrl + account.avatar.path + + return window.location.origin + '/client/assets/images/default-avatar.png' } } diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 5525e4efb..2739ff81a 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts @@ -1,5 +1,6 @@ // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript +import { environment } from '../../../environments/environment' import { AuthService } from '../../core/auth' function getParameterByName (name: string, url: string) { @@ -38,8 +39,19 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: any[ }) } +function getAbsoluteAPIUrl () { + let absoluteAPIUrl = environment.apiUrl + if (!absoluteAPIUrl) { + // The API is on the same domain + absoluteAPIUrl = window.location.origin + } + + return absoluteAPIUrl +} + export { viewportHeight, getParameterByName, - populateAsyncUserVideoChannels + populateAsyncUserVideoChannels, + getAbsoluteAPIUrl } diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index 7a962ae3e..83aae4463 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts @@ -57,7 +57,7 @@ export class User implements UserServerModel { return hasUserRight(this.role, right) } - getAvatarPath () { - return Account.GET_ACCOUNT_AVATAR_PATH(this.account) + getAvatarUrl () { + return Account.GET_ACCOUNT_AVATAR_URL(this.account) } } diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index d97edbcbe..58ddaa5ee 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts @@ -5,6 +5,7 @@ import 'rxjs/add/operator/map' import { UserCreate, UserUpdateMe } from '../../../../../shared' import { environment } from '../../../environments/environment' import { RestExtractor } from '../rest' +import { User } from './user.model' @Injectable() export class UserService { @@ -34,9 +35,24 @@ export class UserService { .catch(res => this.restExtractor.handleError(res)) } + changeAvatar (avatarForm: FormData) { + const url = UserService.BASE_USERS_URL + 'me/avatar/pick' + + return this.authHttp.post(url, avatarForm) + .catch(this.restExtractor.handleError) + } + signup (userCreate: UserCreate) { return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) .map(this.restExtractor.extractDataBool) .catch(res => this.restExtractor.handleError(res)) } + + getMyInformation () { + const url = UserService.BASE_USERS_URL + 'me' + + return this.authHttp.get(url) + .map((userHash: any) => new User(userHash)) + .catch(res => this.restExtractor.handleError(res)) + } } diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index bfe46bcdd..354373776 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts @@ -83,7 +83,7 @@ export abstract class AbstractVideoList implements OnInit { this.videos = this.videos.concat(videos) } }, - error => this.notificationsService.error('Error', error.text) + error => this.notificationsService.error('Error', error.message) ) } diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index f159464c5..060bf933f 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts @@ -2,6 +2,7 @@ import { User } from '../' import { Video as VideoServerModel } from '../../../../../shared' import { Account } from '../../../../../shared/models/actors' import { environment } from '../../../environments/environment' +import { getAbsoluteAPIUrl } from '../misc/utils' export class Video implements VideoServerModel { accountName: string @@ -48,11 +49,7 @@ export class Video implements VideoServerModel { } constructor (hash: VideoServerModel) { - let absoluteAPIUrl = environment.apiUrl - if (!absoluteAPIUrl) { - // The API is on the same domain - absoluteAPIUrl = window.location.origin - } + const absoluteAPIUrl = getAbsoluteAPIUrl() this.accountName = hash.accountName this.createdAt = new Date(hash.createdAt.toString()) diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index 81e3a0d19..0fefcee28 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss @@ -144,8 +144,3 @@ } } } - -.little-information { - font-size: 0.8em; - font-style: italic; -} diff --git a/client/src/app/videos/+video-edit/video-add.component.scss b/client/src/app/videos/+video-edit/video-add.component.scss index 891f38819..4bb509009 100644 --- a/client/src/app/videos/+video-edit/video-add.component.scss +++ b/client/src/app/videos/+video-edit/video-add.component.scss @@ -34,30 +34,9 @@ } .button-file { - position: relative; - overflow: hidden; - display: inline-block; + @include peertube-button-file(190px); + margin-bottom: 45px; - width: 190px; - - @include peertube-button; - @include orange-button; - - input[type=file] { - position: absolute; - top: 0; - right: 0; - min-width: 100%; - min-height: 100%; - font-size: 100px; - text-align: right; - filter: alpha(opacity=0); - opacity: 0; - outline: none; - background: white; - cursor: inherit; - display: block; - } } } } diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index 4afd6160c..0f44d3dd7 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -148,7 +148,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.router.navigate(['/videos/list']) }, - error => this.notificationsService.error('Error', error.text) + error => this.notificationsService.error('Error', error.message) ) } ) @@ -185,7 +185,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { error => { this.descriptionLoading = false - this.notificationsService.error('Error', error.text) + this.notificationsService.error('Error', error.message) } ) } @@ -217,7 +217,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { } getAvatarPath () { - return Account.GET_ACCOUNT_AVATAR_PATH(this.video.account) + return Account.GET_ACCOUNT_AVATAR_URL(this.video.account) } getVideoTags () { @@ -247,7 +247,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.router.navigate([ '/videos/list' ]) }, - error => this.notificationsService.error('Error', error.text) + error => this.notificationsService.error('Error', error.message) ) } ) diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 252cf2173..140de1b2c 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -84,6 +84,32 @@ @include peertube-button; } +@mixin peertube-button-file ($width) { + position: relative; + overflow: hidden; + display: inline-block; + width: $width; + + @include peertube-button; + @include orange-button; + + input[type=file] { + position: absolute; + top: 0; + right: 0; + min-width: 100%; + min-height: 100%; + font-size: 100px; + text-align: right; + filter: alpha(opacity=0); + opacity: 0; + outline: none; + background: white; + cursor: inherit; + display: block; + } +} + @mixin avatar ($size) { width: $size; height: $size; 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 { diff --git a/shared/models/activitypub/activitypub-actor.ts b/shared/models/activitypub/activitypub-actor.ts index d9f80b94c..78256e9be 100644 --- a/shared/models/activitypub/activitypub-actor.ts +++ b/shared/models/activitypub/activitypub-actor.ts @@ -27,6 +27,10 @@ export interface ActivityPubActor { } // Not used - // icon: string[] + icon: { + type: 'Image' + mediaType: 'image/png' + url: string + } // liked: string } diff --git a/shared/models/actors/account.model.ts b/shared/models/actors/account.model.ts index d14701317..ef6fca539 100644 --- a/shared/models/actors/account.model.ts +++ b/shared/models/actors/account.model.ts @@ -4,6 +4,7 @@ export interface Account { id: number uuid: string name: string + displayName: string host: string followingCount: number followersCount: number