Show discussion avatar (#14053)
* Show discussion avatar * Create room avatar endpoint * Fix livechat room avatar * Fix avatar when showing names * Only get room avatars for discussions * Split avatar endpoints into multiple files * Better router files organization * Better logic for requesting discussion's avatar * Fix avatar autocomplete for channels * Better avatar cache invalidation * Don't use Session on serverpull/14185/head
parent
4d160bf9dc
commit
535324e94a
@ -1,28 +1,30 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Session } from 'meteor/session'; |
||||
import { Template } from 'meteor/templating'; |
||||
import { getAvatarUrlFromUsername } from '../../../utils'; |
||||
import { getUserAvatarURL } from '../../../utils/lib/getUserAvatarURL'; |
||||
|
||||
Template.avatar.helpers({ |
||||
src() { |
||||
let { url } = Template.instance().data; |
||||
if (!url) { |
||||
let { username } = this; |
||||
if (username == null && this.userId != null) { |
||||
const user = Meteor.users.findOne(this.userId); |
||||
username = user && user.username; |
||||
} |
||||
if (username == null) { |
||||
return; |
||||
} |
||||
Session.get(`avatar_random_${ username }`); |
||||
const { url } = Template.instance().data; |
||||
if (url) { |
||||
return url; |
||||
} |
||||
|
||||
if (this.roomIcon) { |
||||
username = `@${ username }`; |
||||
} |
||||
let { username } = this; |
||||
if (username == null && this.userId != null) { |
||||
const user = Meteor.users.findOne(this.userId); |
||||
username = user && user.username; |
||||
} |
||||
if (!username) { |
||||
return; |
||||
} |
||||
|
||||
url = getAvatarUrlFromUsername(username); |
||||
Session.get(`avatar_random_${ username }`); |
||||
|
||||
if (this.roomIcon) { |
||||
username = `@${ username }`; |
||||
} |
||||
return url; |
||||
|
||||
return getUserAvatarURL(username); |
||||
}, |
||||
}); |
||||
|
@ -0,0 +1,10 @@ |
||||
import { getURL } from './getURL'; |
||||
|
||||
export const getAvatarURL = ({ username, roomId, cache }) => { |
||||
if (username) { |
||||
return getURL(`/avatar/${ encodeURIComponent(username) }${ cache ? `?_dc=${ cache }` : '' }`); |
||||
} |
||||
if (roomId) { |
||||
return getURL(`/avatar/room/${ encodeURIComponent(roomId) }${ cache ? `?_dc=${ cache }` : '' }`); |
||||
} |
||||
}; |
@ -1,21 +0,0 @@ |
||||
import { Session } from 'meteor/session'; |
||||
import { settings } from '../../settings'; |
||||
|
||||
export const getAvatarUrlFromUsername = function(username) { |
||||
const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); |
||||
if (externalSource !== '') { |
||||
return externalSource.replace('{username}', username); |
||||
} |
||||
const key = `avatar_random_${ username }`; |
||||
const random = typeof Session !== 'undefined' && typeof Session.keys[key] !== 'undefined' ? Session.keys[key] : 0; |
||||
if (username == null) { |
||||
return; |
||||
} |
||||
const cdnPrefix = (settings.get('CDN_PREFIX') || '').trim().replace(/\/$/, ''); |
||||
const pathPrefix = (__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '').trim().replace(/\/$/, ''); |
||||
let path = pathPrefix; |
||||
if (cdnPrefix) { |
||||
path = cdnPrefix + pathPrefix; |
||||
} |
||||
return `${ path }/avatar/${ encodeURIComponent(username) }?_dc=${ random }`; |
||||
}; |
@ -0,0 +1,13 @@ |
||||
import { settings } from '../../settings'; |
||||
import { getAvatarURL } from './getAvatarURL'; |
||||
|
||||
export const getRoomAvatarURL = function(roomId) { |
||||
const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); |
||||
if (externalSource !== '') { |
||||
return externalSource.replace('{roomId}', roomId); |
||||
} |
||||
if (!roomId) { |
||||
return; |
||||
} |
||||
return getAvatarURL({ roomId }); |
||||
}; |
@ -0,0 +1,18 @@ |
||||
import { Session } from 'meteor/session'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { settings } from '../../settings'; |
||||
import { getAvatarURL } from './getAvatarURL'; |
||||
|
||||
export const getUserAvatarURL = function(username) { |
||||
const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, ''); |
||||
if (externalSource !== '') { |
||||
return externalSource.replace('{username}', username); |
||||
} |
||||
if (username == null) { |
||||
return; |
||||
} |
||||
const key = `avatar_random_${ username }`; |
||||
const cache = Tracker.nonreactive(() => Session && Session.get(key)); // there is no Session on server
|
||||
|
||||
return getAvatarURL({ username, cache }); |
||||
}; |
@ -0,0 +1,9 @@ |
||||
import { WebApp } from 'meteor/webapp'; |
||||
|
||||
import { roomAvatar } from './room'; |
||||
import { userAvatar } from './user'; |
||||
|
||||
import './middlewares'; |
||||
|
||||
WebApp.connectHandlers.use('/avatar/room/', roomAvatar); |
||||
WebApp.connectHandlers.use('/avatar/', userAvatar); |
@ -0,0 +1,15 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { userCanAccessAvatar } from '../utils'; |
||||
|
||||
// protect all avatar endpoints
|
||||
export const protectAvatars = Meteor.bindEnvironment((req, res, next) => { |
||||
if (!userCanAccessAvatar(req)) { |
||||
res.writeHead(403); |
||||
res.write('Forbidden'); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
return next(); |
||||
}); |
@ -0,0 +1,5 @@ |
||||
import { WebApp } from 'meteor/webapp'; |
||||
|
||||
import { protectAvatars } from './auth'; |
||||
|
||||
WebApp.connectHandlers.use('/avatar/', protectAvatars); |
@ -0,0 +1,41 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { Rooms } from '../../../app/models/server'; |
||||
import { roomTypes } from '../../../app/utils'; |
||||
|
||||
import { |
||||
renderSVGLetters, |
||||
serveAvatar, |
||||
wasFallbackModified, |
||||
setCacheAndDispositionHeaders, |
||||
} from './utils'; |
||||
|
||||
const getRoom = (roomId) => { |
||||
const room = Rooms.findOneById(roomId, { fields: { t: 1, prid: 1, name: 1, fname: 1 } }); |
||||
|
||||
// if it is a discussion, returns the parent room
|
||||
if (room.prid) { |
||||
return Rooms.findOneById(room.prid, { fields: { t: 1, name: 1, fname: 1 } }); |
||||
} |
||||
return room; |
||||
}; |
||||
|
||||
export const roomAvatar = Meteor.bindEnvironment(function(req, res/* , next*/) { |
||||
const roomId = req.url.substr(1); |
||||
const room = getRoom(roomId); |
||||
|
||||
const roomName = roomTypes.getConfig(room.t).roomName(room); |
||||
|
||||
setCacheAndDispositionHeaders(req, res); |
||||
|
||||
const reqModifiedHeader = req.headers['if-modified-since']; |
||||
if (!wasFallbackModified(reqModifiedHeader, res)) { |
||||
res.writeHead(304); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
const svg = renderSVGLetters(roomName, req.query.size && parseInt(req.query.size)); |
||||
|
||||
return serveAvatar(svg, req.query.format, res); |
||||
}); |
@ -0,0 +1,78 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { FileUpload } from '../../../app/file-upload'; |
||||
import { settings } from '../../../app/settings/server'; |
||||
import { Users, Avatars } from '../../../app/models/server'; |
||||
|
||||
import { |
||||
renderSVGLetters, |
||||
serveAvatar, |
||||
wasFallbackModified, |
||||
setCacheAndDispositionHeaders, |
||||
} from './utils'; |
||||
|
||||
// request /avatar/@name forces returning the svg
|
||||
export const userAvatar = Meteor.bindEnvironment(function(req, res) { |
||||
const requestUsername = decodeURIComponent(req.url.substr(1).replace(/\?.*$/, '')); |
||||
|
||||
if (!requestUsername) { |
||||
res.writeHead(404); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
const avatarSize = req.query.size && parseInt(req.query.size); |
||||
|
||||
setCacheAndDispositionHeaders(req, res); |
||||
|
||||
// if request starts with @ always return the svg letters
|
||||
if (requestUsername[0] === '@') { |
||||
const svg = renderSVGLetters(requestUsername.substr(1), avatarSize); |
||||
serveAvatar(svg, req.query.format, res); |
||||
return; |
||||
} |
||||
|
||||
const reqModifiedHeader = req.headers['if-modified-since']; |
||||
|
||||
const file = Avatars.findOneByName(requestUsername); |
||||
if (file) { |
||||
res.setHeader('Content-Security-Policy', 'default-src \'none\''); |
||||
|
||||
if (reqModifiedHeader && reqModifiedHeader === (file.uploadedAt && file.uploadedAt.toUTCString())) { |
||||
res.setHeader('Last-Modified', reqModifiedHeader); |
||||
res.writeHead(304); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
res.setHeader('Last-Modified', file.uploadedAt.toUTCString()); |
||||
res.setHeader('Content-Type', file.type); |
||||
res.setHeader('Content-Length', file.size); |
||||
|
||||
return FileUpload.get(file, req, res); |
||||
} |
||||
|
||||
// if still using "letters fallback"
|
||||
if (!wasFallbackModified(reqModifiedHeader, res)) { |
||||
res.writeHead(304); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
let svg = renderSVGLetters(requestUsername, avatarSize); |
||||
|
||||
if (settings.get('UI_Use_Name_Avatar')) { |
||||
const user = Users.findOneByUsername(requestUsername, { |
||||
fields: { |
||||
name: 1, |
||||
}, |
||||
}); |
||||
|
||||
if (user && user.name) { |
||||
svg = renderSVGLetters(user.name, avatarSize); |
||||
} |
||||
} |
||||
|
||||
serveAvatar(svg, req.query.format, res); |
||||
return; |
||||
}); |
@ -0,0 +1,93 @@ |
||||
import sharp from 'sharp'; |
||||
import { throttle } from 'underscore'; |
||||
|
||||
import { Cookies } from 'meteor/ostrio:cookies'; |
||||
|
||||
import { Users } from '../../../app/models/server'; |
||||
import { getAvatarColor } from '../../../app/utils'; |
||||
import { settings } from '../../../app/settings/server'; |
||||
|
||||
const FALLBACK_LAST_MODIFIED = 'Thu, 01 Jan 2015 00:00:00 GMT'; |
||||
|
||||
const cookie = new Cookies(); |
||||
|
||||
export const serveAvatar = (avatar, format, res) => { |
||||
res.setHeader('Last-Modified', FALLBACK_LAST_MODIFIED); |
||||
|
||||
if (['png', 'jpg', 'jpeg'].includes(format)) { |
||||
res.setHeader('Content-Type', `image/${ format }`); |
||||
sharp(new Buffer(avatar)) |
||||
.toFormat(format) |
||||
.pipe(res); |
||||
return; |
||||
} |
||||
res.setHeader('Content-Type', 'image/svg+xml'); |
||||
|
||||
res.write(avatar); |
||||
res.end(); |
||||
}; |
||||
|
||||
export const wasFallbackModified = (reqModifiedHeader) => { |
||||
if (!reqModifiedHeader || reqModifiedHeader !== FALLBACK_LAST_MODIFIED) { |
||||
return true; |
||||
} |
||||
return false; |
||||
}; |
||||
|
||||
function isUserAuthenticated({ headers, query }) { |
||||
let { rc_uid, rc_token } = query; |
||||
|
||||
if (!rc_uid && headers.cookie) { |
||||
rc_uid = cookie.get('rc_uid', headers.cookie) ; |
||||
rc_token = cookie.get('rc_token', headers.cookie); |
||||
} |
||||
|
||||
const userFound = Users.findOneByIdAndLoginToken(rc_uid, rc_token, { fields: { _id: 1 } }); |
||||
|
||||
return !!rc_uid && !!rc_token && !!userFound; |
||||
} |
||||
|
||||
const warnUnauthenticatedAccess = throttle(() => { |
||||
console.warn('The server detected an unauthenticated access to an user avatar. This type of request will soon be blocked by default.'); |
||||
}, 60000 * 30); // 30 minutes
|
||||
|
||||
export function userCanAccessAvatar({ headers = {}, query = {} }) { |
||||
const isAuthenticated = isUserAuthenticated({ headers, query }); |
||||
|
||||
if (settings.get('Accounts_AvatarBlockUnauthenticatedAccess') === true) { |
||||
return isAuthenticated; |
||||
} |
||||
|
||||
if (!isAuthenticated) { |
||||
warnUnauthenticatedAccess(); |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
const getFirstLetter = (name) => name.replace(/[^A-Za-z0-9]/g, '').substr(0, 1).toUpperCase(); |
||||
|
||||
export const renderSVGLetters = (username, viewSize = 200) => { |
||||
let color = ''; |
||||
let initials = ''; |
||||
|
||||
if (username === '?') { |
||||
color = '#000'; |
||||
initials = username; |
||||
} else { |
||||
color = getAvatarColor(username); |
||||
initials = getFirstLetter(username); |
||||
} |
||||
|
||||
const fontSize = viewSize / 1.6; |
||||
|
||||
return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${ viewSize } ${ viewSize }\">\n<rect width=\"100%\" height=\"100%\" fill=\"${ color }\"/>\n<text x=\"50%\" y=\"50%\" dy=\"0.36em\" text-anchor=\"middle\" pointer-events=\"none\" fill=\"#ffffff\" font-family=\"'Helvetica', 'Arial', 'Lucida Grande', 'sans-serif'\" font-size="${ fontSize }">\n${ initials }\n</text>\n</svg>`; |
||||
}; |
||||
|
||||
const getCacheTime = (cacheTime) => cacheTime || settings.get('Accounts_AvatarCacheTime'); |
||||
|
||||
export function setCacheAndDispositionHeaders(req, res) { |
||||
const cacheTime = getCacheTime(req.query.cacheTime); |
||||
res.setHeader('Cache-Control', `public, max-age=${ cacheTime }`); |
||||
res.setHeader('Content-Disposition', 'inline'); |
||||
} |
@ -1,155 +0,0 @@ |
||||
import { WebApp } from 'meteor/webapp'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import _ from 'underscore'; |
||||
import sharp from 'sharp'; |
||||
import { Cookies } from 'meteor/ostrio:cookies'; |
||||
import { FileUpload } from '../../app/file-upload'; |
||||
import { getAvatarColor } from '../../app/utils'; |
||||
import { Users, Avatars } from '../../app/models'; |
||||
import { settings } from '../../app/settings'; |
||||
|
||||
const cookie = new Cookies(); |
||||
|
||||
function isUserAuthenticated(req) { |
||||
const headers = req.headers || {}; |
||||
const query = req.query || {}; |
||||
|
||||
let { rc_uid, rc_token } = query; |
||||
|
||||
if (!rc_uid && headers.cookie) { |
||||
rc_uid = cookie.get('rc_uid', headers.cookie) ; |
||||
rc_token = cookie.get('rc_token', headers.cookie); |
||||
} |
||||
|
||||
if (!rc_uid || !rc_token || !Users.findOneByIdAndLoginToken(rc_uid, rc_token)) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
const warnUnauthenticatedAccess = _.debounce(() => { |
||||
console.warn('The server detected an unauthenticated access to an user avatar. This type of request will soon be blocked by default.'); |
||||
}, 60000 * 30); // 30 minutes
|
||||
|
||||
function userCanAccessAvatar(req) { |
||||
if (settings.get('Accounts_AvatarBlockUnauthenticatedAccess') === true) { |
||||
return isUserAuthenticated(req); |
||||
} |
||||
|
||||
if (!isUserAuthenticated(req)) { |
||||
warnUnauthenticatedAccess(); |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
Meteor.startup(function() { |
||||
WebApp.connectHandlers.use('/avatar/', Meteor.bindEnvironment(function(req, res/* , next*/) { |
||||
const params = { |
||||
username: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')), |
||||
}; |
||||
const cacheTime = req.query.cacheTime || settings.get('Accounts_AvatarCacheTime'); |
||||
|
||||
if (_.isEmpty(params.username) || !userCanAccessAvatar(req)) { |
||||
res.writeHead(403); |
||||
res.write('Forbidden'); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
const match = /^\/([^?]*)/.exec(req.url); |
||||
|
||||
if (match[1]) { |
||||
let username = decodeURIComponent(match[1]); |
||||
let file; |
||||
|
||||
username = username.replace(/\.jpg$/, ''); |
||||
|
||||
if (username[0] !== '@') { |
||||
file = Avatars.findOneByName(username); |
||||
} |
||||
|
||||
if (file) { |
||||
res.setHeader('Content-Security-Policy', 'default-src \'none\''); |
||||
|
||||
const reqModifiedHeader = req.headers['if-modified-since']; |
||||
if (reqModifiedHeader && reqModifiedHeader === (file.uploadedAt && file.uploadedAt.toUTCString())) { |
||||
res.setHeader('Last-Modified', reqModifiedHeader); |
||||
res.writeHead(304); |
||||
res.end(); |
||||
return; |
||||
} |
||||
|
||||
res.setHeader('Cache-Control', `public, max-age=${ cacheTime }`); |
||||
res.setHeader('Expires', '-1'); |
||||
res.setHeader('Content-Disposition', 'inline'); |
||||
res.setHeader('Last-Modified', file.uploadedAt.toUTCString()); |
||||
res.setHeader('Content-Type', file.type); |
||||
res.setHeader('Content-Length', file.size); |
||||
|
||||
return FileUpload.get(file, req, res); |
||||
} else { |
||||
res.setHeader('Content-Type', 'image/svg+xml'); |
||||
res.setHeader('Cache-Control', `public, max-age=${ cacheTime }`); |
||||
res.setHeader('Expires', '-1'); |
||||
res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); |
||||
|
||||
const reqModifiedHeader = req.headers['if-modified-since']; |
||||
|
||||
if (reqModifiedHeader) { |
||||
if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') { |
||||
res.writeHead(304); |
||||
res.end(); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
if (settings.get('UI_Use_Name_Avatar')) { |
||||
const user = Users.findOneByUsername(username, { |
||||
fields: { |
||||
name: 1, |
||||
}, |
||||
}); |
||||
|
||||
if (user && user.name) { |
||||
username = user.name; |
||||
} |
||||
} |
||||
|
||||
let color = ''; |
||||
let initials = ''; |
||||
|
||||
if (username === '?') { |
||||
color = '#000'; |
||||
initials = username; |
||||
} else { |
||||
color = getAvatarColor(username); |
||||
|
||||
initials = username.replace(/[^A-Za-z0-9]/g, '').substr(0, 1).toUpperCase(); |
||||
} |
||||
|
||||
const viewSize = parseInt(req.query.size) || 200; |
||||
const fontSize = viewSize / 1.6; |
||||
|
||||
const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${ viewSize } ${ viewSize }\">\n<rect width=\"100%\" height=\"100%\" fill=\"${ color }\"/>\n<text x=\"50%\" y=\"50%\" dy=\"0.36em\" text-anchor=\"middle\" pointer-events=\"none\" fill=\"#ffffff\" font-family=\"'Helvetica', 'Arial', 'Lucida Grande', 'sans-serif'\" font-size="${ fontSize }">\n${ initials }\n</text>\n</svg>`; |
||||
|
||||
if (['png', 'jpg', 'jpeg'].includes(req.query.format)) { |
||||
res.setHeader('Content-Type', `image/${ req.query.format }`); |
||||
sharp(new Buffer(svg)) |
||||
.toFormat(req.query.format) |
||||
.pipe(res); |
||||
return; |
||||
} |
||||
|
||||
res.write(svg); |
||||
res.end(); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
res.writeHead(404); |
||||
res.end(); |
||||
return; |
||||
})); |
||||
}); |
Loading…
Reference in new issue