Begin to add avatar to actors

pull/175/head
Chocobozzz 2017-12-29 19:10:13 +01:00
parent 8b0d42ee37
commit c5911fd347
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
41 changed files with 498 additions and 177 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@
/test6/
/uploads/
/videos/
/avatars/
/thumbnails/
/previews/
/certs/

View File

@ -1,5 +1,5 @@
<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>
@ -7,6 +7,10 @@
</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>

View File

@ -21,6 +21,12 @@
}
}
.button-file {
@include peertube-button-file(auto);
margin-top: 10px;
}
.account-title {
text-transform: uppercase;
color: $orange-color;

View File

@ -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)
)
}
}

View File

@ -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)
)
}
)

View File

@ -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<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) {

View File

@ -1,6 +1,6 @@
<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>

View File

@ -51,8 +51,8 @@ export class MenuComponent implements OnInit {
)
}
getUserAvatarPath () {
return this.user.getAvatarPath()
getUserAvatarUrl () {
return this.user.getAvatarUrl()
}
isRegistrationAllowed () {

View File

@ -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'
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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)
)
}

View File

@ -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())

View File

@ -144,8 +144,3 @@
}
}
}
.little-information {
font-size: 0.8em;
font-style: italic;
}

View File

@ -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;
}
}
}
}

View File

@ -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)
)
}
)

View File

@ -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;

View File

@ -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))
)

View File

@ -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

View File

@ -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)
)

View File

@ -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',

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,28 @@
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
}

View File

@ -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<FetchRemoteActorResult> {
@ -151,43 +190,33 @@ async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResu
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,

View File

@ -18,7 +18,7 @@ function getVideoChannelActivityPubUrl (videoChannelUUID: string) {
}
function getAccountActivityPubUrl (accountName: string) {
return CONFIG.WEBSERVER.URL + '/account/' + accountName
return CONFIG.WEBSERVER.URL + '/accounts/' + accountName
}
function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) {

View File

@ -3,12 +3,14 @@ import 'express-validator'
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'
@ -96,6 +98,21 @@ const usersUpdateMeValidator = [
}
]
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'),
@ -145,7 +162,8 @@ export {
usersUpdateMeValidator,
usersVideoRatingValidator,
ensureUserRegistrationAllowed,
usersGetValidator
usersGetValidator,
usersUpdateMyAvatarValidator
}
// ---------------------------------------------------------------------------

View File

@ -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<AccountModel> {
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
}

View File

@ -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<UserModel> {
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<UserModel> {
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

View File

@ -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<ActorModel> {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
onDelete: 'set null'
})
Avatar: AvatarModel
@ -253,11 +261,7 @@ export class ActorModel extends Model<ActorModel> {
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<ActorModel> {
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<ActorModel> {
id: this.getPublicKeyUrl(),
owner: this.url,
publicKeyPem: this.publicKey
}
},
icon
}
return activityPubContextify(json)
@ -353,4 +368,10 @@ export class ActorModel extends Model<ActorModel> {
getHost () {
return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST
}
getAvatarUrl () {
if (!this.avatarId) return undefined
return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath
}
}

View File

@ -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<AvatarModel> {
@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)
}
}

View File

@ -214,7 +214,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
static listThreadCommentsForApi (videoId: number, threadId: number) {
const query = {
order: [ [ 'id', 'ASC' ] ],
order: [ [ 'createdAt', 'DESC' ] ],
where: {
videoId,
[ Sequelize.Op.or ]: [

View File

@ -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 () {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -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,

View File

@ -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
}

View File

@ -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 {

View File

@ -27,6 +27,10 @@ export interface ActivityPubActor {
}
// Not used
// icon: string[]
icon: {
type: 'Image'
mediaType: 'image/png'
url: string
}
// liked: string
}

View File

@ -4,6 +4,7 @@ export interface Account {
id: number
uuid: string
name: string
displayName: string
host: string
followingCount: number
followersCount: number