fixing display of avatars by URL

This commit is contained in:
or-else
2021-07-15 17:29:06 -07:00
parent 4219e47529
commit 0afef6c587
24 changed files with 220 additions and 165 deletions
+2 -2
View File
@@ -45,12 +45,12 @@ export const MEDIA_BREAKPOINT = 640;
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
export const REM_SIZE = 13;
// Size of the avatar image: when an image is uploaded, it's resized to
// Size of the avatar image: when image dimensions are greater or the image is not square, it's resized to
// a square of this size or less.
export const AVATAR_SIZE = 384;
// Maximum size of an avatar in bytes for sending in-band. Bigger avatars will be sent out of band (as uploads).
export const MAX_AVATAR_BYTES = 8192;
export const MAX_AVATAR_BYTES = 4096;
// Size of the broken_image shown in MessagesView
export const BROKEN_IMAGE_SIZE = 32;
+2 -2
View File
@@ -6,8 +6,8 @@ import { bytesToHumanSize } from './strformat.js'
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
// Make a data: URL from public.photo
export function makeImageDataUrl(photo) {
// Get an URL from a theCard photo: either create a data: URL or return reference URL.
export function makeImageUrl(photo) {
if (photo) {
if (photo.data && photo.type) {
const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);
+5 -5
View File
@@ -38,10 +38,12 @@ export function theCard(fn, imageUrl, imageMimeType) {
if (matches) {
mimeType = matches[1];
card.photo = {
data: imageUrl.substring(imageUrl.indexOf(',') + 1)
data: imageUrl.substring(imageUrl.indexOf(',') + 1),
ref: Tinode.DEL_CHAR
};
} else {
card.photo = {
data: Tinode.DEL_CHAR,
ref: imageUrl
};
}
@@ -107,10 +109,9 @@ export function isUrlRelative(url) {
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
// If apikey and/or token is defined, it's appended to relative URL.
export function sanitizeUrl(url, allowedSchemes) {
if (!url) {
return null;
if (typeof url != 'string') {
return url;
}
// Strip control characters and whitespace. They are not valid URL characters anyway.
@@ -139,7 +140,6 @@ export function sanitizeUrl(url, allowedSchemes) {
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
// have http:, https:, blob: or data: scheme.
// The relative URL is appended with the API key and/or auth token.
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
export function sanitizeImageUrl(url) {
if (!url) {
+7 -9
View File
@@ -12,7 +12,7 @@ import TagManager from '../widgets/tag-manager.jsx';
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
MAX_INBAND_ATTACHMENT_SIZE, MAX_TITLE_LENGTH } from '../config.js';
import { imageScaled, blobToBase64, makeImageDataUrl } from '../lib/blob-helpers.js';
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, asEmail, asPhone, theCard } from '../lib/utils.js';
export default class AccGeneralView extends React.Component {
@@ -22,7 +22,7 @@ export default class AccGeneralView extends React.Component {
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageDataUrl(me.public ? me.public.photo : null),
avatar: makeImageUrl(me.public ? me.public.photo : null),
tags: me.tags(),
credentials: me.getCredentials() || [],
addCredActive: false,
@@ -100,10 +100,6 @@ export default class AccGeneralView extends React.Component {
if (blob.size > MAX_AVATAR_BYTES) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
this.setState({uploading: true});
uploader.upload(blob)
@@ -117,17 +113,19 @@ export default class AccGeneralView extends React.Component {
this.setState({uploading: false});
});
} else {
this.setState({uploading: true});
// Convert blob to base64-encoded bits.
blobToBase64(blob, (unused, base64bits) => {
const du = makeImageDataUrl({data: base64bits, type: mime});
const du = makeImageUrl({data: base64bits, type: mime});
this.setState({source: du});
this.props.onUpdateAccount(undefined, theCard(null, du));
this.setState({uploading: false});
});
}
};
if (width > AVATAR_SIZE || height > AVATAR_SIZE) {
// Avatar is too large even after scaling. Shring it and make square.
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
// Avatar is not square or too large even after cropping. Shrink it and make square.
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
readyToUpload,
(err) => {
+6 -2
View File
@@ -55,9 +55,13 @@ export default class AvatarCropView extends React.PureComponent {
onChange={this.handleChange} />
</div>
<div className="dialog-buttons">
{this.props.onCancel ?
<button className="secondary" onClick={this.props.onCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
: null}
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok"
defaultMessage="OK" description="Button [OK]" />
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
</div>
</div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
export default class EditAccountView extends React.Component {
constructor(props) {
@@ -14,7 +14,7 @@ export default class EditAccountView extends React.Component {
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageDataUrl(me.public ? me.public.photo : null)
avatar: makeImageUrl(me.public ? me.public.photo : null)
};
}
+2 -2
View File
@@ -18,7 +18,7 @@ import TagManager from '../widgets/tag-manager.jsx';
import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, theCard } from '../lib/utils.js';
const messages = defineMessages({
@@ -237,7 +237,7 @@ class InfoView extends React.Component {
muted: acs && acs.isMuted(),
fullName: topic.public ? topic.public.fn : undefined,
avatar: makeImageDataUrl(topic.public ? topic.public.photo : null),
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
trustedBadges: badges,
private: topic.private ? topic.private.comment : null,
address: topic.name,
+4 -8
View File
@@ -19,7 +19,7 @@ import SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
imageScaled, makeImageDataUrl } from '../lib/blob-helpers.js';
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
@@ -265,7 +265,7 @@ class MessagesView extends React.Component {
if (topic.public) {
Object.assign(nextState, {
title: topic.public.fn,
avatar: makeImageDataUrl(topic.public.photo)
avatar: makeImageUrl(topic.public.photo)
});
} else {
Object.assign(nextState, {
@@ -395,7 +395,7 @@ class MessagesView extends React.Component {
if (desc.public) {
this.setState({
title: desc.public.fn,
avatar: makeImageDataUrl(desc.public.photo)
avatar: makeImageUrl(desc.public.photo)
});
} else {
this.setState({
@@ -645,10 +645,6 @@ class MessagesView extends React.Component {
if (file.size > maxInbandAttachmentSize) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, {
mime: file.type,
@@ -873,7 +869,7 @@ class MessagesView extends React.Component {
const user = topic.userDesc(thisFrom);
if (user && user.public) {
userName = user.public.fn;
userAvatar = makeImageDataUrl(user.public.photo);
userAvatar = makeImageUrl(user.public.photo);
}
userFrom = thisFrom;
chatBoxClass='chat-box group';
+3
View File
@@ -118,6 +118,8 @@ class SidepanelView extends React.Component {
badges = null;
}
console.log("avatar URL=", avatar);
let onCancel;
if (['login', 'contacts'].indexOf(view) == -1) {
onCancel = this.props.onCancel;
@@ -129,6 +131,7 @@ class SidepanelView extends React.Component {
state={view}
title={title}
avatar={avatar}
tinode={this.props.tinode}
trustedBadges={badges}
myUserId={this.props.myUserId}
onSignUp={this.props.onSignUp}
+10 -12
View File
@@ -18,7 +18,7 @@ import SidepanelView from './sidepanel-view.jsx';
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, LOGGING_ENABLED,
MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageDataUrl } from '../lib/blob-helpers.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js';
import HashNavigation from '../lib/navigation.js';
@@ -308,7 +308,7 @@ class TinodeWeb extends React.Component {
initFCMessaging() {
const {formatMessage, locale} = this.props.intl;
const onError = (msg, err) => {
console.log(msg, err);
console.error(msg, err);
this.handleError(formatMessage(messages.push_init_failed), 'err');
this.setState({desktopAlertsEnabled: false});
}
@@ -384,7 +384,7 @@ class TinodeWeb extends React.Component {
'cred','reset','newtpk','archive','blocked','contacts',''].includes(hash.path[0])) {
this.setState({sidePanelSelected: hash.path[0]});
} else {
console.log("Unknown sidepanel view", hash.path[0]);
console.info("Unknown sidepanel view", hash.path[0]);
}
// Topic for MessagesView selector.
@@ -464,13 +464,11 @@ class TinodeWeb extends React.Component {
handlePersistenceChange(persist) {
if (persist) {
this.tinode.initStorage().then(() => {
console.log("storage initialized");
LocalStorageUtil.setObject('keep-logged-in', true);
this.setState({persist: true});
});
} else {
this.tinode.clearStorage().then(() => {
console.log("storage cleared");
LocalStorageUtil.setObject('keep-logged-in', false);
this.setState({persist: false});
});
@@ -653,7 +651,7 @@ class TinodeWeb extends React.Component {
if (desc.public) {
this.setState({
sidePanelTitle: desc.public.fn,
sidePanelAvatar: makeImageDataUrl(desc.public.photo)
sidePanelAvatar: makeImageUrl(desc.public.photo)
});
}
if (desc.trusted) {
@@ -721,7 +719,7 @@ class TinodeWeb extends React.Component {
} else {
// TODO(gene): handle other types of notifications:
// * ua -- user agent changes (maybe display a pictogram for mobile/desktop).
console.log("Unsupported (yet) presence update:" + what + " in: " + cont.topic);
console.info("Unsupported (yet) presence update:" + what + " in: " + cont.topic);
}
}
@@ -945,7 +943,7 @@ class TinodeWeb extends React.Component {
});
break;
default:
console.log("Unknown invitation action", '"' + action + '""');
console.info("Unknown invitation action", '"' + action + '""');
}
if (response != null) {
@@ -1077,7 +1075,7 @@ class TinodeWeb extends React.Component {
}).then(() => {
this.requestPushToken();
}).catch((err) => {
console.log("Failed to get notification permission.", err);
console.error("Failed to get notification permission.", err);
this.handleError(err.message, 'err');
this.setState({desktopAlerts: false, firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
@@ -1090,7 +1088,7 @@ class TinodeWeb extends React.Component {
}
} else if (this.state.firebaseToken && this.fbPush) {
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
console.log("Unable to delete token.", err);
console.error("Unable to delete token.", err);
}).finally(() => {
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
localStorage.removeItem('firebase-token');
@@ -1119,7 +1117,7 @@ class TinodeWeb extends React.Component {
}
}).catch((err) => {
this.handleError(err.message, 'err');
console.log("Failed to retrieve firebase token", err);
console.error("Failed to retrieve firebase token", err);
});
}
@@ -1154,7 +1152,7 @@ class TinodeWeb extends React.Component {
let path = '';
if (['security','support','general','notif'].includes(parsed.path[0])) {
path = 'edit';
} else if ('crop') {
} else if ('crop' == parsed.path[0]) {
path = 'general';
} else if ('blocked' == parsed.path[0]) {
path = 'security';
+3 -1
View File
@@ -3,6 +3,8 @@ import React from 'react';
import LetterTile from './letter-tile.jsx';
import LoadSpinner from './load-spinner.jsx';
import { sanitizeImageUrl } from '../lib/utils.js';
import { AVATAR_SIZE } from '../config.js';
export default class AvatarUpload extends React.Component {
@@ -42,7 +44,7 @@ export default class AvatarUpload extends React.Component {
<i className="material-icons">clear</i>
</a>}
{this.state.source ?
<img src={this.state.source} className="preview" /> :
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
this.props.readOnly && this.props.uid ?
<div className="avatar-box">
<LetterTile
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import Chip from './chip.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
/* BEGIN ChipInput: group membership widget */
export default class ChipInput extends React.Component {
@@ -119,7 +119,7 @@ export default class ChipInput extends React.Component {
chips.push(
<Chip
onCancel={this.handleChipCancel}
avatar={makeImageDataUrl(item.public ? item.public.photo : null)}
avatar={makeImageUrl(item.public ? item.public.photo : null)}
title={item.public ? item.public.fn : undefined}
noAvatar={this.props.avatarDisabled}
topic={item.user}
+2 -2
View File
@@ -9,7 +9,7 @@ import Tinode from 'tinode-sdk';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
@@ -90,7 +90,7 @@ class ContactList extends React.Component {
contactNodes.push(
<Contact
title={c.public ? c.public.fn : null}
avatar={makeImageDataUrl(c.public ? c.public.photo : null)}
avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment}
preview={preview}
received={deliveryStatus}
+5 -3
View File
@@ -129,10 +129,12 @@ export default class Cropper extends React.Component {
});
// Initial zoom level fills the bounding box at the smallest image dimension and overflows the largest, i.e. "fill" not "fit".
let zoom = Math.max((this.bBoxRect.width / imgRect.width), (this.bBoxRect.height / imgRect.height));
const zoom = Math.max(this.bBoxRect.width / imgRect.width, this.bBoxRect.height / imgRect.height);
// Converting from viewport coordinates to container, then panning.
let panX = (this.cutoutRect.left - this.bBoxRect.left) - ((imgRect.width - this.cutoutRect.width) / 2);
let panY = (this.cutoutRect.top - this.bBoxRect.top) - ((imgRect.height - this.cutoutRect.height) / 2);
const panX = this.cutoutRect.left - this.bBoxRect.left -
(imgRect.width - this.cutoutRect.width) / 2;
const panY = this.cutoutRect.top - this.bBoxRect.top -
(imgRect.height - this.cutoutRect.height) / 2;
this.positionAll(panX, panY, zoom);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { FormattedMessage } from 'react-intl';
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
import LetterTile from './letter-tile.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
export default class GroupSubs extends React.Component {
constructor(props) {
@@ -23,7 +23,7 @@ export default class GroupSubs extends React.Component {
<div className="avatar-box" key={sub.user}>
<LetterTile
topic={sub.user}
avatar={makeImageDataUrl(sub.public ? sub.public.photo : null) || true}
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
title={sub.public ? sub.public.fn : null} />
</div>
);
+1 -1
View File
@@ -23,7 +23,7 @@ export default class LetterTile extends React.PureComponent {
} else if (this.props.avatar) {
// If avatar image is invalid, show a placeholder.
avatar = <img className="avatar" alt="avatar" src={this.props.avatar}
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />;
onError={(e)=>{e.target.onerror = null; e.target.src="../img/broken_image.png"}} />;
} else {
avatar = null;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import CheckBox from './checkbox.jsx';
import Contact from './contact.jsx';
import { makeImageDataUrl } from '../lib/blob-helpers.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
// Translatable permission names.
const messages = defineMessages({
@@ -135,7 +135,7 @@ class PermissionsEditor extends React.Component {
<ul className="contact-box"><Contact
item={this.props.item}
title={this.props.userTitle}
avatar={makeImageDataUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
<label className="small"><FormattedMessage id="title_permissions"
defaultMessage="Permissions" description="Section title"/></label>
<table className="permission-editor">
+8 -3
View File
@@ -1,12 +1,13 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
import ButtonBack from './button-back.jsx';
import ContactBadges from './contact-badges.jsx';
import MenuContacts from './menu-contacts.jsx';
import MenuStart from './menu-start.jsx';
import { sanitizeImageUrl } from '../lib/utils.js';
export default class SideNavbar extends React.PureComponent {
render() {
const icon_badges = [];
@@ -15,13 +16,17 @@ export default class SideNavbar extends React.PureComponent {
icon_badges.push({icon: b, color: 'badge-inv'});
});
}
let avatar = null;
if (this.props.tinode) {
avatar = this.props.tinode.authorizeURL(sanitizeImageUrl(this.props.avatar));
}
return (
<div id="side-caption-panel" className="caption-panel">
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
{this.props.avatar ?
{avatar ?
<div id="self-avatar" className="avatar-box">
<LetterTile
avatar={this.props.avatar}
avatar={avatar}
topic={this.props.myUserId}
title={this.props.title} />
</div>