diff --git a/package-lock.json b/package-lock.json index 0291319..5c9f375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 292a783..d9fe031 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/http.js b/scripts/http.js index 2f1a5c7..ec33af0 100644 --- a/scripts/http.js +++ b/scripts/http.js @@ -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 } diff --git a/server/api/external.js b/server/api/external.js index ee1bd80..0dd41fc 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -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 = { diff --git a/server/api/image.js b/server/api/image.js new file mode 100644 index 0000000..e60d81c --- /dev/null +++ b/server/api/image.js @@ -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 +} diff --git a/server/api/index.js b/server/api/index.js index 3e96192..c5baed3 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -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']) diff --git a/server/routes/api.js b/server/routes/api.js index 06e07f9..a8007d4 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -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'}) diff --git a/server/routes/index.js b/server/routes/index.js index 8b2024b..89bea9d 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -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 }) diff --git a/server/server.js b/server/server.js index e253dcb..8819252 100644 --- a/server/server.js +++ b/server/server.js @@ -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) diff --git a/src/script/main.js b/src/script/main.js index 384e00e..8957024 100644 --- a/src/script/main.js +++ b/src/script/main.js @@ -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) { diff --git a/src/style/main.styl b/src/style/main.styl index e1e36eb..d6d0f7c 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -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 diff --git a/usercontent/.gitignore b/usercontent/.gitignore new file mode 100644 index 0000000..e1a3e06 --- /dev/null +++ b/usercontent/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!images/ diff --git a/usercontent/images/.gitignore b/usercontent/images/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/usercontent/images/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/views/email_change.pug b/views/email_change.pug index 7d89ecf..6af4295 100644 --- a/views/email_change.pug +++ b/views/email_change.pug @@ -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") diff --git a/views/includes/avatar.pug b/views/includes/avatar.pug new file mode 100644 index 0000000..ed48022 --- /dev/null +++ b/views/includes/avatar.pug @@ -0,0 +1,6 @@ +.avatar + if user.avatar_file + img(src="/usercontent/images/" + user.avatar_file) + else + .noavatar + i.fa.fa-fw.fa-user diff --git a/views/partials/avatar.pug b/views/partials/avatar.pug index 84f6aa2..081fa52 100644 --- a/views/partials/avatar.pug +++ b/views/partials/avatar.pug @@ -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() + diff --git a/views/settings.pug b/views/settings.pug index 3937ce6..65d37a2 100644 --- a/views/settings.pug +++ b/views/settings.pug @@ -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