avatars and some fixes

This commit is contained in:
Evert Prants 2017-08-25 19:42:30 +03:00
parent 4c7f72f65c
commit ab06f1a4cd
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
17 changed files with 559 additions and 20 deletions

66
package-lock.json generated
View File

@ -141,12 +141,22 @@
"integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=",
"dev": true
},
"array-parallel": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz",
"integrity": "sha1-j3hTCJJu1apHjEfmTRszS2wMlH0="
},
"array-reduce": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz",
"integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=",
"dev": true
},
"array-series": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz",
"integrity": "sha1-3103v8XC7wdV4qpPkv6ufUtaly8="
},
"array-union": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
@ -2152,6 +2162,14 @@
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
},
"fd-slicer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz",
"integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
"requires": {
"pend": "1.2.0"
}
},
"figures": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz",
@ -2410,6 +2428,41 @@
"pinkie-promise": "2.0.1"
}
},
"gm": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/gm/-/gm-1.23.0.tgz",
"integrity": "sha1-gKL+nL8TFRUCSEZERlhGEmn1JmE=",
"requires": {
"array-parallel": "0.1.3",
"array-series": "0.1.5",
"cross-spawn": "4.0.2",
"debug": "2.2.0"
},
"dependencies": {
"cross-spawn": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz",
"integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=",
"requires": {
"lru-cache": "4.1.1",
"which": "https://registry.npmjs.org/which/-/which-1.2.14.tgz"
}
},
"debug": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
"integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
"requires": {
"ms": "0.7.1"
}
},
"ms": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
"integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
}
}
},
"graceful-fs": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
@ -3509,6 +3562,14 @@
"version": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"multiparty": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/multiparty/-/multiparty-4.1.3.tgz",
"integrity": "sha1-PEPH/LGJbhdGBDap3Qtu8WaOT5Q=",
"requires": {
"fd-slicer": "1.0.1"
}
},
"mute-stream": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz",
@ -3823,6 +3884,11 @@
"sha.js": "2.4.8"
}
},
"pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA="
},
"performance-now": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz",

View File

@ -40,7 +40,9 @@
"express": "^4.15.3",
"express-rate-limit": "^2.9.0",
"express-session": "^1.15.3",
"gm": "^1.23.0",
"knex": "^0.13.0",
"multiparty": "^4.1.3",
"mysql": "^2.13.0",
"nodemailer": "^4.0.1",
"notp": "^2.0.3",

View File

@ -1,5 +1,6 @@
import url from 'url'
import qs from 'querystring'
import fs from 'fs'
function HTTP_GET (link, headers = {}, lback) {
if (lback && lback >= 4) throw new Error('infinite loop!') // Prevent infinite loop requests
@ -105,7 +106,24 @@ function HTTP_POST (link, headers = {}, data) {
})
}
async function Download (url, dest) {
return new Promise((resolve, reject) => {
let file = fs.createWriteStream(dest)
let protocol = url.indexOf('https:') === 0 ? require('https') : require('http')
protocol.get(url, function (response) {
response.pipe(file)
file.on('finish', function () {
file.close(resolve)
})
}).on('error', function (err) {
fs.unlink(dest)
reject(err)
})
})
}
module.exports = {
GET: HTTP_GET,
POST: HTTP_POST
POST: HTTP_POST,
Download: Download
}

View File

@ -4,6 +4,9 @@ import models from './models'
import UAPI from './index'
import qs from 'querystring'
import oauth from 'oauth-libre'
import fs from 'fs'
import path from 'path'
import url from 'url'
let twitterApp
let discordApp
@ -50,6 +53,23 @@ const API = {
}
return models.External.query().delete().where('user_id', user.id).andWhere('service', service)
},
saveAvatar: async (avatarUrl) => {
if (!avatarUrl) return null
let imgdir = path.join(__dirname, '../../', 'usercontent', 'images')
let imageName = 'download-' + UAPI.Hash(12)
let uridata = url.parse(avatarUrl)
let pathdata = path.parse(uridata.path)
imageName += pathdata.ext || '.png'
try {
await http.Download(avatarUrl, path.join(imgdir, imageName))
} catch (e) {
return null
}
return {fileName: imageName}
}
},
Facebook: {
@ -96,11 +116,13 @@ const API = {
}
// Determine profile picture
let profilepic = ''
let profilepic = null
if (fbdata.picture) {
if (fbdata.picture.is_silhouette === false && fbdata.picture.url) {
// TODO: Download the profile image and save it locally
profilepic = fbdata.picture.url
let imgdata = await API.Common.saveAvatar(fbdata.picture.url)
if (imgdata && imgdata.fileName) {
profilepic = imgdata.fileName
}
}
}
@ -211,10 +233,12 @@ const API = {
}
// Determine profile picture
let profilepic = ''
let profilepic = null
if (twdata.profile_image_url_https) {
// TODO: Download the profile image and save it locally
profilepic = twdata.profile_image_url_https
let imgdata = await API.Common.saveAvatar(twdata.profile_image_url_https)
if (imgdata && imgdata.fileName) {
profilepic = imgdata.fileName
}
}
// Create a new user
@ -326,8 +350,7 @@ const API = {
}
// Determine profile picture
let profilepic = ''
// TODO: Download the profile image and save it locally
let profilepic = null
// Create a new user
let udataLimited = {

109
server/api/image.js Normal file
View File

@ -0,0 +1,109 @@
import gm from 'gm'
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import Promise from 'bluebird'
const fsBlue = Promise.promisifyAll(fs)
const uploads = path.join(__dirname, '../../', 'usercontent')
const maxFileSize = 1000000
const imageTypes = {
'image/png': '.png',
'image/jpg': '.jpg',
'image/jpeg': '.jpeg'
}
function saneFields (fields) {
let out = {}
for (let i in fields) {
let entry = fields[i]
if (typeof entry === 'object' && entry.length === 1 && !isNaN(parseInt(entry[0]))) {
out[i] = parseInt(entry[0])
}
}
return out
}
async function bailOut (file, error) {
await fsBlue.unlinkAsync(file)
return { error: error }
}
async function uploadImage (username, fields, files) {
let directory = path.join(uploads, 'images')
if (!files.image) return {error: 'No image file'}
let file = files.image[0]
if (file.size > maxFileSize) return bailOut(file.path, 'Image is too large! 1 MB max')
fields = saneFields(fields)
// Get file info, generate a file name
let fileHash = crypto.randomBytes(12).toString('hex')
let contentType = file.headers['content-type']
if (!contentType) return bailOut(file.path, 'Invalid of missing content-type header')
file = file.path
// Make sure content type is allowed
let match = false
for (let i in imageTypes) {
if (i === contentType) {
match = true
break
}
}
if (!match) return bailOut(file, 'Invalid image type. Only PNG, JPG and JPEG files are allowed.')
let extension = imageTypes[contentType]
let fileName = username + '-' + fileHash + extension
// Check for cropping
if (fields.x == null || fields.y == null || fields.width == null || fields.height == null) {
return bailOut(file, 'Images can only be cropped on the server side due to security reasons.')
}
if (fields.x < 0 || fields.y < 0 || fields.x > fields.width + fields.x || fields.y > fields.height + fields.y) {
return bailOut(file, 'Impossible crop.')
}
// Check 1 : 1 aspect ratio
if (Math.floor(fields.width / fields.height) !== 1) {
return bailOut(file, 'Avatars can only have an aspect ratio of 1:1')
}
if (fields.scaleX) {
fields.x *= fields.scaleX
fields.width *= fields.scaleX
}
if (fields.scaleY) {
fields.y *= fields.scaleY
fields.height *= fields.scaleY
}
// Crop
try {
await new Promise(function (resolve, reject) {
gm(file)
.crop(fields.width, fields.height, fields.x, fields.y)
.write(path.join(directory, fileName), (err) => {
if (err) return reject(err)
resolve(fileName)
})
})
await fs.unlinkAsync(file)
} catch (e) {
console.error(e)
return bailOut(file, 'An error occured while cropping.')
}
return {file: fileName}
}
module.exports = {
uploadImage: uploadImage
}

View File

@ -6,6 +6,7 @@ import crypto from 'crypto'
import notp from 'notp'
import base32 from 'thirty-two'
import emailer from './emailer'
import fs from 'fs'
const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
@ -119,6 +120,38 @@ const API = {
return models.User.query().patchAndFetchById(user.id, data)
},
changeAvatar: async function (user, fileName) {
user = await API.User.ensureObject(user, ['avatar_file'])
let uploadsDir = path.join(__dirname, '../../', 'usercontent', 'images')
let pathOf = path.join(uploadsDir, fileName)
if (!fs.existsSync(pathOf)) {
return {error: 'No such file'}
}
// Delete previous upload
if (user.avatar_file != null) {
let file = path.join(uploadsDir, user.avatar_file)
if (fs.existsSync(file)) {
fs.unlinkSync(file)
}
}
await API.User.update(user, {avatar_file: fileName})
return { file: fileName }
},
removeAvatar: async function (user) {
user = await API.User.ensureObject(user, ['avatar_file'])
let uploadsDir = path.join(__dirname, '../../', 'usercontent', 'images')
if (!user.avatar_file) return {}
let file = path.join(uploadsDir, user.avatar_file)
if (fs.existsSync(file)) {
fs.unlinkSync(file)
}
return API.User.update(user, {avatar_file: null})
},
Login: {
password: async function (user, password) {
user = await API.User.ensureObject(user, ['password'])

View File

@ -1,9 +1,12 @@
import express from 'express'
import RateLimit from 'express-rate-limit'
import multiparty from 'multiparty'
import config from '../../scripts/load-config'
import wrap from '../../scripts/asyncRoute'
import APIExtern from '../api/external'
import API from '../api'
import News from '../api/news'
import Image from '../api/image'
import APIExtern from '../api/external'
let router = express.Router()
@ -13,6 +16,12 @@ let apiLimiter = new RateLimit({
delayMs: 0
})
let uploadLimiter = new RateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
delayMs: 0
})
router.use(apiLimiter)
// Turn things like 'key1[key2]': 'value' into key1: {key2: 'value'} because facebook
@ -277,6 +286,46 @@ router.get('/news', wrap(async (req, res) => {
res.jsonp(articles)
}))
async function promiseForm (req) {
let form = new multiparty.Form()
return new Promise(function (resolve, reject) {
form.parse(req, async (err, fields, files) => {
if (err) return reject(err)
resolve({fields: fields, files: files})
})
})
}
router.post('/avatar', uploadLimiter, wrap(async (req, res, next) => {
if (!req.session.user) return next()
let data = await promiseForm(req)
let result = await Image.uploadImage(req.session.user.username, data.fields, data.files)
if (result.error) {
return res.status(400).jsonp({error: result.error})
}
let avatarUpdate = await API.User.changeAvatar(req.session.user, result.file)
if (avatarUpdate.error) {
return res.status(400).jsonp({error: avatarUpdate.error})
}
if (avatarUpdate.file) {
req.session.user.avatar_file = avatarUpdate.file
}
res.status(200).jsonp({})
}))
router.post('/avatar/remove', wrap(async (req, res, next) => {
if (!req.session.user) return next()
await API.User.removeAvatar(req.session.user)
req.session.user.avatar_file = null
res.status(200).jsonp({done: true})
}))
// 404
router.use((req, res) => {
res.status(404).jsonp({error: 'Not found'})

View File

@ -192,7 +192,9 @@ router.get('/user/manage/email', ensureLogin, wrap(async (req, res) => {
obfuscated = rep + '@' + split[1]
}
res.render('email_change', {email: obfuscated})
let socialStatus = await API.User.socialStatus(req.session.user)
res.render('email_change', {email: obfuscated, password: socialStatus.password})
}))
/*
@ -519,22 +521,28 @@ router.post('/user/manage/email', wrap(async (req, res, next) => {
return formError(req, res, 'Invalid session! Try reloading the page.')
}
let user = req.session.user
let user = await API.User.get(req.session.user)
let email = req.body.email
let newEmail = req.body.email_new
let password = req.body.password
if (!password || !newEmail || (!email && user.email != null)) {
if (!newEmail || (!email && user.email !== '')) {
return formError(req, res, 'Please fill in all of the fields.')
}
if (req.session.user.email != null && email !== user.email) {
if (req.session.user.email !== '' && email !== user.email) {
return formError(req, res, 'The email you provided is incorrect.')
}
let passwordMatch = await API.User.Login.password(user, password)
if (!passwordMatch) {
return formError(req, res, 'The password you provided is incorrect.')
if (user.password != null && user.password !== '') {
if (!password) {
return formError(req, res, 'Enter a password.')
}
let passwordMatch = await API.User.Login.password(user, password)
if (!passwordMatch) {
return formError(req, res, 'The password you provided is incorrect.')
}
}
let emailValid = API.User.Register.validateEmail(newEmail)
@ -542,6 +550,11 @@ router.post('/user/manage/email', wrap(async (req, res, next) => {
return formError(req, res, 'Invalid email address.')
}
let emailTaken = await API.User.get(newEmail)
if (emailTaken) {
return formError(req, res, 'This email is already taken.')
}
let success = await API.User.update(user, {
email: newEmail
})

View File

@ -61,6 +61,7 @@ module.exports = (args) => {
app.use('/style', express.static(path.join(__dirname, '../build/style'), { maxAge: staticAge }))
app.use('/script', express.static(path.join(__dirname, '../build/script'), { maxAge: staticAge }))
app.use('/static', express.static(path.join(__dirname, '../static'), { maxAge: staticAge }))
app.use('/usercontent', express.static(path.join(__dirname, '../usercontent'), { maxAge: staticAge }))
app.use(routes)

View File

@ -131,6 +131,24 @@ $(document).ready(function () {
})
}
if ($('#newAvatar').length) {
$('#newAvatar').click(function (e) {
e.preventDefault()
window.Dialog.openPartial('Change Avatar', 'avatar')
})
$('#removeAvatar').click(function (e) {
e.preventDefault()
$.ajax({
type: 'POST',
url: '/api/avatar/remove',
success: function (data) {
window.location.reload()
}
})
})
}
window.checkLoginState = function () {
var FB = window.FB
FB.getLoginStatus(function (response) {

View File

@ -485,6 +485,40 @@ input.invalid
cursor: pointer
padding: 5px
.cropbox
padding: 10px
.preview
max-width: 160px
max-height: 160px
.buttons .button
margin-right: 5px
.avatarCont
height: 180px
.avatar
float: left
.options
margin-left: 170px
a
display: block
.avatar
width: 160px
height: 160px
position: relative
border: 1px solid #ddd
img
position: absolute
width: 100%
height: 100%
margin: auto
.noavatar
font-size: 120px
text-align: center
line-height: 160px
color: #fff
background-color: #d0d0d0
@media all and (max-width: 800px)
.navigator
padding: 0 10px

3
usercontent/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!.gitignore
!images/

2
usercontent/images/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -23,6 +23,7 @@ block body
input(type="email", name="email", id="email")
label(for="email_new") New Email Address
input(type="email", name="email_new", id="email_new")
label(for="password") Password
input(type="password", name="password", id="password")
if password
label(for="password") Password
input(type="password", name="password", id="password")
input(type="submit", value="Change")

View File

@ -0,0 +1,6 @@
.avatar
if user.avatar_file
img(src="/usercontent/images/" + user.avatar_file)
else
.noavatar
i.fa.fa-fw.fa-user

View File

@ -1 +1,155 @@
img(src=user.avatar_file)
.rel.cropbox
link(rel="stylesheet", type="text/css", href="https://cdnjs.cloudflare.com/ajax/libs/cropper/2.3.4/cropper.min.css")
script(src="https://cdnjs.cloudflare.com/ajax/libs/cropper/2.3.4/cropper.min.js")
.otherdata
h3 Current Avatar
.avatar
include ../includes/avatar.pug
.inputting
h3 Upload new
.message.error
input(type="file", id="fileinput")
.editor(style="display: none")
h3 Crop the image
img.preview(id="image")
.buttons
.button#done Done
.button#cancel Cancel
.button#upload Upload Now
script.
window.jQuery = $
function message (msg) {
$('.message').text(msg)
$('.message').show()
}
function dataURItoBlob (dataURI) {
// convert base64 to raw binary data held in a string
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
var byteString = atob(dataURI.split(',')[1])
// separate out the mime component
var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
// write the bytes of the string to an ArrayBuffer
var ab = new ArrayBuffer(byteString.length)
// create a view into the buffer
var ia = new Uint8Array(ab)
// set the bytes of the buffer to the correct values
for (var i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i)
}
// write the ArrayBuffer to a blob, and you're done
var blob = new Blob([ab], {type: mimeString})
return blob
}
function cropReady() {
let cropargs = $('#image').cropper('getData')
let cropimage = $('#image').cropper('getCroppedCanvas')
$('#upload').show()
$('#done').hide()
$('.preview').attr('src', cropimage.toDataURL())
$('.preview').show()
$('#image').cropper('destroy')
let called = false
$('#upload').click(function (e) {
if (called) return
called = true
$('#upload').hide()
let formData = new FormData()
formData.append('image', dataURItoBlob(fr.result))
for (let i in cropargs) {
formData.append(i, cropargs[i])
}
$.ajax({
type: 'POST',
url: '/api/avatar',
data: formData,
processData: false,
contentType: false,
success: function (data) {
window.Dialog.close()
window.location.reload()
},
error: function (err) {
if (err.responseJSON && err.responseJSON.error) {
message(err.responseJSON.error)
}
$('#cancel').click()
}
})
})
}
function ready (blob) {
let match = blob.match(/data:image\/(\w+);/)
if (!match) {
return message('Not an image file!')
}
if (match[1] !== 'png' && match[1] !== 'jpg' && match[1] !== 'jpeg') {
return message('Unsupported image file')
}
$('#image').attr('src', fr.result).hide()
$('.inputting').hide()
$('.otherdata').hide()
$('#upload').hide()
$('#done').show()
$('.editor').show()
$('#image').cropper({
aspectRatio: 1 / 1,
minContainerHeight: 512,
minContainerWidth: 512,
viewMode: 1
})
}
function handleFileSelect() {
if (!window.File || !window.FileReader || !window.FileList || !window.Blob) {
return message('The File APIs are not fully supported in this browser.')
}
let input = document.getElementById('fileinput')
if (!input.files) {
message('This browser doesn\'t seem to support the `files` property of file inputs.')
} else if (!input.files[0]) {
message('Please select a file.')
} else {
file = input.files[0]
fr = new FileReader()
fr.readAsDataURL(file)
fr.addEventListener('load', function (e) {
ready(fr.result)
})
return
}
}
$('#fileinput').on('change', function (e) {
e.preventDefault()
handleFileSelect()
})
$('#cancel').click(function (e) {
$('.inputting').show()
$('.otherdata').show()
$('.editor').hide()
$('#image').cropper('destroy')
})
$('#done').click(function (e) {
cropReady()
})
$('.message').hide()

View File

@ -21,6 +21,13 @@ block body
input(type="text", name="username", id="username", value=user.username, disabled)
label(for="display_name") Display Name
input(type="text", name="display_name", id="display_name", value=user.display_name)
label(for="display_name") Avatar
.avatarCont
include includes/avatar.pug
.options
a#newAvatar(href='#') Change Avatar
if user.avatar_file
a#removeAvatar(href='#') Remove Avatar
input(type="submit", value="Save Settings")
.right
h3 Social Media Accounts