/test6/
/uploads/
/videos/
+/avatars/
/thumbnails/
/previews/
/certs/
<div class="user">
- <img [src]="getAvatarPath()" alt="Avatar" />
+ <img [src]="getAvatarUrl()" alt="Avatar" />
<div class="user-info">
<div class="user-info-username">{{ user.username }}</div>
</div>
</div>
+<div class="button-file">
+ <span>Change your avatar</span>
+ <input #avatarfileInput type="file" name="avatarfile" id="avatarfile" (change)="changeAvatar()" />
+</div>
<div class="account-title">Account settings</div>
<my-account-change-password></my-account-change-password>
}
}
+.button-file {
+ @include peertube-button-file(auto);
+
+ margin-top: 10px;
+}
+
.account-title {
text-transform: uppercase;
color: $orange-color;
-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',
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)
+ )
}
}
.subscribe(
res => this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`),
- err => this.notificationsService.error('Error', err.text)
+ err => this.notificationsService.error('Error', err.message)
)
}
)
this.spliceVideosById(video.id)
},
- error => this.notificationsService.error('Error', error.text)
+ error => this.notificationsService.error('Error', error.message)
)
}
)
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'
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 {
const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
return this.http.get<UserServerModel>(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) {
<menu>
<div *ngIf="isLoggedIn" class="logged-in-block">
- <img [src]="getUserAvatarPath()" alt="Avatar" />
+ <img [src]="getUserAvatarUrl()" alt="Avatar" />
<div class="logged-in-info">
<a routerLink="/account/settings" class="logged-in-username">{{ user.username }}</a>
)
}
- getUserAvatarPath () {
- return this.user.getAvatarPath()
+ getUserAvatarUrl () {
+ return this.user.getAvatarUrl()
}
isRegistrationAllowed () {
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
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'
}
}
// 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) {
})
}
+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
}
return hasUserRight(this.role, right)
}
- getAvatarPath () {
- return Account.GET_ACCOUNT_AVATAR_PATH(this.account)
+ getAvatarUrl () {
+ return Account.GET_ACCOUNT_AVATAR_URL(this.account)
}
}
import { UserCreate, UserUpdateMe } from '../../../../../shared'
import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest'
+import { User } from './user.model'
@Injectable()
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))
+ }
}
this.videos = this.videos.concat(videos)
}
},
- error => this.notificationsService.error('Error', error.text)
+ error => this.notificationsService.error('Error', error.message)
)
}
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
}
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())
}
}
}
-
-.little-information {
- font-size: 0.8em;
- font-style: italic;
-}
}
.button-file {
- position: relative;
- overflow: hidden;
- display: inline-block;
- margin-bottom: 45px;
- width: 190px;
-
- @include peertube-button;
- @include orange-button;
+ @include peertube-button-file(190px);
- 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;
- }
+ margin-bottom: 45px;
}
}
}
this.router.navigate(['/videos/list'])
},
- error => this.notificationsService.error('Error', error.text)
+ error => this.notificationsService.error('Error', error.message)
)
}
)
error => {
this.descriptionLoading = false
- this.notificationsService.error('Error', error.text)
+ this.notificationsService.error('Error', error.message)
}
)
}
}
getAvatarPath () {
- return Account.GET_ACCOUNT_AVATAR_PATH(this.video.account)
+ return Account.GET_ACCOUNT_AVATAR_URL(this.video.account)
}
getVideoTags () {
this.router.navigate([ '/videos/list' ])
},
- error => this.notificationsService.error('Error', error.text)
+ error => this.notificationsService.error('Error', error.message)
)
}
)
@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;
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))
)
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',
asyncMiddleware(updateMe)
)
+usersRouter.post('/me/avatar/pick',
+ authenticate,
+ reqAvatarFile,
+ usersUpdateMyAvatarValidator,
+ asyncMiddleware(updateMyAvatar)
+)
+
usersRouter.put('/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
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
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
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)
)
videosRouter.post('/upload',
authenticate,
- reqFiles,
+ reqVideoFile,
asyncMiddleware(videosAddValidator),
asyncMiddleware(addVideoRetryWrapper)
)
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',
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'
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 {
isUserVideoQuotaValid,
isUserUsernameValid,
isUserDisplayNSFWValid,
- isUserAutoPlayVideoValid
+ isUserAutoPlayVideoValid,
+ isAvatarFile
}
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'
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)
resetSequelizeInstance,
getServerActor,
SortType,
- getHostWithPort
+ getHostWithPort,
+ createReqFiles
}
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 145
+const LAST_MIGRATION_VERSION = 150
// ---------------------------------------------------------------------------
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 }
'video/mp4': '.mp4'
}
+const AVATAR_MIMETYPE_EXT = {
+ 'image/png': '.png',
+ 'image/jpg': '.jpg',
+ 'image/jpeg': '.jpg'
+}
+
// ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube'
PREVIEWS: '/static/previews/',
THUMBNAILS: '/static/thumbnails/',
TORRENTS: '/static/torrents/',
- WEBSEED: '/static/webseed/'
+ WEBSEED: '/static/webseed/',
+ AVATARS: '/static/avatars/'
}
// Cache control
VIDEO_PRIVACIES,
VIDEO_LICENCES,
VIDEO_RATE_TYPES,
- VIDEO_MIMETYPE_EXT
+ VIDEO_MIMETYPE_EXT,
+ AVATAR_MIMETYPE_EXT
}
--- /dev/null
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction,
+ queryInterface: Sequelize.QueryInterface,
+ sequelize: Sequelize.Sequelize
+}): Promise<void> {
+ 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
+}
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'
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,
// 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 })
actor: ActorModel
name: string
summary: string
+ avatarName?: string
attributedTo: ActivityPubAttributedTo[]
}
async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResult> {
followingUrl: actorJSON.following
})
+ // Fetch icon?
+ let avatarName: string = undefined
+ if (
+ actorJSON.icon && actorJSON.icon.type === 'Image' && actorJSON.icon.mediaType === 'image/png' &&
+ isActivityPubUrlValid(actorJSON.icon.url)
+ ) {
+ const extension = actorJSON.icon.mediaType === 'image/png' ? '.png' : '.jpg'
+
+ avatarName = uuidv4() + extension
+ const destPath = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
+
+ await doRequestAndSaveToFile({
+ method: 'GET',
+ uri: actorJSON.icon.url
+ }, destPath)
+ }
+
const name = actorJSON.name || actorJSON.preferredUsername
return {
actor,
name,
+ avatarName,
summary: actorJSON.summary,
attributedTo: actorJSON.attributedTo
}
}
-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,
- saveActorAndServerAndModelIfNotExist,
- fetchRemoteActor,
- buildActorInstance,
- setAsyncActorKeys
-}
-
-// ---------------------------------------------------------------------------
-
async function fetchActorTotalItems (url: string) {
const options = {
uri: url,
}
function getAccountActivityPubUrl (accountName: string) {
- return CONFIG.WEBSERVER.URL + '/account/' + accountName
+ return CONFIG.WEBSERVER.URL + '/accounts/' + accountName
}
function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) {
import { body, param } from 'express-validator/check'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
import {
+ isAvatarFile,
isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid,
isUserVideoQuotaValid
} from '../../helpers/custom-validators/users'
-import { isVideoExist } from '../../helpers/custom-validators/videos'
+import { isVideoExist, isVideoFile } from '../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger'
import { isSignupAllowed } from '../../helpers/utils'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
import { UserModel } from '../../models/account/user'
import { areValidationErrors } from './utils'
}
]
+const usersUpdateMyAvatarValidator = [
+ body('avatarfile').custom((value, { req }) => 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'),
usersUpdateMeValidator,
usersVideoRatingValidator,
ensureUserRegistrationAllowed,
- usersGetValidator
+ usersGetValidator,
+ usersUpdateMyAvatarValidator
}
// ---------------------------------------------------------------------------
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'
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
}
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
return comparePassword(password, this.password)
}
- toFormattedJSON () {
+ toFormattedJSON (): User {
const json = {
id: this.id,
username: this.username,
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
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,
{
model: () => ServerModel,
required: false
+ },
+ {
+ model: () => AvatarModel,
+ required: false
}
]
})
{
model: () => ServerModel,
required: false
+ },
+ {
+ model: () => AvatarModel,
+ required: false
}
]
}
foreignKey: {
allowNull: true
},
- onDelete: 'cascade'
+ onDelete: 'set null'
})
Avatar: AvatarModel
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
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,
id: this.getPublicKeyUrl(),
owner: this.url,
publicKeyPem: this.publicKey
- }
+ },
+ icon
}
return activityPubContextify(json)
getHost () {
return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST
}
+
+ getAvatarUrl () {
+ if (!this.avatarId) return undefined
+
+ return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath
+ }
}
-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'
@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)
+ }
}
static listThreadCommentsForApi (videoId: number, threadId: number) {
const query = {
- order: [ [ 'id', 'ASC' ] ],
+ order: [ [ 'createdAt', 'DESC' ] ],
where: {
videoId,
[ Sequelize.Op.or ]: [
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'
})
})
+ 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 () {
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'
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,
+import { isAbsolute, join } from 'path'
import * as request from 'supertest'
-import { makePutBodyRequest } from '../'
+import { makePostUploadRequest, makePutBodyRequest } from '../'
import { UserRole } from '../../../../shared/index'
})
}
+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,
removeUser,
updateUser,
updateMyUser,
- getUserInformation
+ getUserInformation,
+ updateMyAvatar
}
.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']) {
.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 {
}
// Not used
- // icon: string[]
+ icon: {
+ type: 'Image'
+ mediaType: 'image/png'
+ url: string
+ }
// liked: string
}
id: number
uuid: string
name: string
+ displayName: string
host: string
followingCount: number
followersCount: number