Client's public folder renamed to static
Regular campaign sender seems to have most of the code in place. (Not tested.)
13
.gitignore
vendored
|
@ -15,19 +15,6 @@ dump.rdb
|
|||
# generate POT file every time you want to update your PO file
|
||||
languages/mailtrain.pot
|
||||
|
||||
public/mosaico/uploads/*
|
||||
!public/mosaico/uploads/README.md
|
||||
public/mosaico/custom/*
|
||||
!public/mosaico/custom/README.md
|
||||
public/mosaico/templates/*
|
||||
!public/mosaico/templates/versafix-1
|
||||
|
||||
public/grapejs/uploads/*
|
||||
!public/grapejs/uploads/README.md
|
||||
public/grapejs/templates/*
|
||||
!public/grapejs/templates/demo
|
||||
!public/grapejs/templates/aves
|
||||
|
||||
config/production.toml
|
||||
workers/reports/config/production.toml
|
||||
docker-compose.override.yml
|
||||
|
|
|
@ -6,8 +6,8 @@ The migration should almost happen automatically. There are however the followin
|
|||
and update your configs accordingly.
|
||||
|
||||
2. Images uploaded in a template editor (Mosaico, GrapeJS, etc.) need to be manually moved to a new destination (under `client`).
|
||||
For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/public/mosaico`.
|
||||
For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/static/mosaico`.
|
||||
|
||||
3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/public/mosaico/templates`.
|
||||
3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/static/mosaico/templates`.
|
||||
|
||||
4. Imports are not migrated. If you have any pending imports, complete them before migration to v2.
|
142
app-builder.js
|
@ -54,6 +54,9 @@ const index = require('./routes/index');
|
|||
|
||||
const interoperableErrors = require('./shared/interoperable-errors');
|
||||
|
||||
const { getTrustedUrl } = require('./lib/urls');
|
||||
const { AppType } = require('./shared/app');
|
||||
|
||||
hbs.registerPartials(__dirname + '/views/partials');
|
||||
hbs.registerPartials(__dirname + '/views/subscription/partials/');
|
||||
|
||||
|
@ -104,7 +107,8 @@ hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer
|
|||
handlebarsHelpers.registerHelpers(hbs.handlebars);
|
||||
|
||||
|
||||
function createApp(trusted) {
|
||||
|
||||
function createApp(appType) {
|
||||
const app = express();
|
||||
|
||||
function install404Fallback(url) {
|
||||
|
@ -171,9 +175,9 @@ function createApp(trusted) {
|
|||
limit: config.www.postSize
|
||||
}));
|
||||
|
||||
if (trusted) {
|
||||
if (appType === AppType.TRUSTED) {
|
||||
passport.setupRegularAuth(app);
|
||||
} else {
|
||||
} else if (appType === AppType.SANDBOXED) {
|
||||
app.use(passport.tryAuthByRestrictedAccessToken);
|
||||
}
|
||||
|
||||
|
@ -189,52 +193,6 @@ function createApp(trusted) {
|
|||
next();
|
||||
});
|
||||
|
||||
/* FIXME - can we remove this???
|
||||
app.use((req, res, next) => {
|
||||
res.locals.flash = req.flash.bind(req);
|
||||
res.locals.user = req.user;
|
||||
res.locals.admin = req.user && req.user.id == 1; // FIXME, this should verify the admin privileges and set this accordingly
|
||||
res.locals.ldap = {
|
||||
enabled: config.ldap.enabled,
|
||||
passwordresetlink: config.ldap.passwordresetlink
|
||||
};
|
||||
|
||||
let menu = [{
|
||||
title: _('Home'),
|
||||
url: '/',
|
||||
selected: true
|
||||
}];
|
||||
|
||||
res.setSelectedMenu = key => {
|
||||
menu.forEach(item => {
|
||||
item.selected = (item.key === key);
|
||||
});
|
||||
};
|
||||
|
||||
res.locals.menu = menu;
|
||||
tools.updateMenu(res);
|
||||
|
||||
res.locals.customStyles = config.customstyles || [];
|
||||
res.locals.customScripts = config.customscripts || [];
|
||||
|
||||
let bodyClasses = [];
|
||||
if (req.user) {
|
||||
bodyClasses.push('logged-in user-' + req.user.username);
|
||||
}
|
||||
res.locals.bodyClass = bodyClasses.join(' ');
|
||||
|
||||
getSettings(['uaCode', 'shoutout'], (err, configItems) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
Object.keys(configItems).forEach(key => {
|
||||
res.locals[key] = configItems[key];
|
||||
});
|
||||
next();
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// Endpoint under /api are authenticated by access token
|
||||
app.all('/api/*', passport.authByAccessToken);
|
||||
|
||||
|
@ -244,62 +202,64 @@ function createApp(trusted) {
|
|||
next();
|
||||
});
|
||||
|
||||
|
||||
app.all('/rest/*', (req, res, next) => {
|
||||
req.needsRESTJSONResponse = true;
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// Initializes the request context to be used for authorization
|
||||
app.use((req, res, next) => {
|
||||
req.context = contextHelpers.getRequestContext(req);
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// Regular endpoints
|
||||
useWith404Fallback('/subscription', subscription);
|
||||
useWith404Fallback('/files', files);
|
||||
useWith404Fallback('/mosaico', mosaico.getRouter(trusted));
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
useWith404Fallback('/reports', reports);
|
||||
if (appType === AppType.PUBLIC) {
|
||||
useWith404Fallback('/subscription', subscription);
|
||||
}
|
||||
|
||||
if (appType === AppType.TRUSTED || appType === AppType.SANDBOXED) {
|
||||
// Regular endpoints
|
||||
useWith404Fallback('/files', files);
|
||||
useWith404Fallback('/mosaico', mosaico.getRouter(appType));
|
||||
|
||||
// API endpoints
|
||||
useWith404Fallback('/api', api);
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
useWith404Fallback('/reports', reports);
|
||||
}
|
||||
|
||||
// REST endpoints
|
||||
app.use('/rest', namespacesRest);
|
||||
app.use('/rest', sendConfigurationsRest);
|
||||
app.use('/rest', usersRest);
|
||||
app.use('/rest', accountRest);
|
||||
app.use('/rest', campaignsRest);
|
||||
app.use('/rest', triggersRest);
|
||||
app.use('/rest', listsRest);
|
||||
app.use('/rest', formsRest);
|
||||
app.use('/rest', fieldsRest);
|
||||
app.use('/rest', importsRest);
|
||||
app.use('/rest', importRunsRest);
|
||||
app.use('/rest', sharesRest);
|
||||
app.use('/rest', segmentsRest);
|
||||
app.use('/rest', subscriptionsRest);
|
||||
app.use('/rest', templatesRest);
|
||||
app.use('/rest', mosaicoTemplatesRest);
|
||||
app.use('/rest', blacklistRest);
|
||||
app.use('/rest', editorsRest);
|
||||
app.use('/rest', filesRest);
|
||||
app.use('/rest', settingsRest);
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
app.use('/rest', reportTemplatesRest);
|
||||
app.use('/rest', reportsRest);
|
||||
// API endpoints
|
||||
useWith404Fallback('/api', api);
|
||||
|
||||
// REST endpoints
|
||||
app.use('/rest', namespacesRest);
|
||||
app.use('/rest', sendConfigurationsRest);
|
||||
app.use('/rest', usersRest);
|
||||
app.use('/rest', accountRest);
|
||||
app.use('/rest', campaignsRest);
|
||||
app.use('/rest', triggersRest);
|
||||
app.use('/rest', listsRest);
|
||||
app.use('/rest', formsRest);
|
||||
app.use('/rest', fieldsRest);
|
||||
app.use('/rest', importsRest);
|
||||
app.use('/rest', importRunsRest);
|
||||
app.use('/rest', sharesRest);
|
||||
app.use('/rest', segmentsRest);
|
||||
app.use('/rest', subscriptionsRest);
|
||||
app.use('/rest', templatesRest);
|
||||
app.use('/rest', mosaicoTemplatesRest);
|
||||
app.use('/rest', blacklistRest);
|
||||
app.use('/rest', editorsRest);
|
||||
app.use('/rest', filesRest);
|
||||
app.use('/rest', settingsRest);
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
app.use('/rest', reportTemplatesRest);
|
||||
app.use('/rest', reportsRest);
|
||||
}
|
||||
install404Fallback('/rest');
|
||||
}
|
||||
install404Fallback('/rest');
|
||||
|
||||
app.use('/', index.getRouter(trusted));
|
||||
app.use('/', index.getRouter(appType));
|
||||
|
||||
// Error handlers
|
||||
if (app.get('env') === 'development') {
|
||||
|
@ -333,7 +293,7 @@ function createApp(trusted) {
|
|||
|
||||
} else {
|
||||
if (err instanceof interoperableErrors.NotLoggedInError) {
|
||||
return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
return res.redirect(getTrustedUrl('/account/login?next=' + encodeURIComponent(req.originalUrl)));
|
||||
} else {
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
|
@ -378,7 +338,7 @@ function createApp(trusted) {
|
|||
// TODO: Render interoperable errors using a special client that does internationalization of the error message
|
||||
|
||||
if (err instanceof interoperableErrors.NotLoggedInError) {
|
||||
return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
return res.redirect(getTrustedUrl('/account/login?next=' + encodeURIComponent(req.originalUrl)));
|
||||
} else {
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
|
@ -393,6 +353,4 @@ function createApp(trusted) {
|
|||
return app;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApp
|
||||
};
|
||||
module.exports.createApp = createApp;
|
||||
|
|
|
@ -40,7 +40,6 @@ export default class CustomContent extends Component {
|
|||
|
||||
const t = props.t;
|
||||
|
||||
console.log(props);
|
||||
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
|
||||
|
||||
this.customTemplateTypeOptions = [];
|
||||
|
|
|
@ -29,3 +29,56 @@ export function getCampaignLabels(t) {
|
|||
campaignTypeLabels
|
||||
};
|
||||
}
|
||||
|
||||
/* FIXME - this is not used at the moment, but it's kept here because it will be probably needed at some later point of time.
|
||||
export function getDefaultMergeTags(t) {
|
||||
return [{
|
||||
key: 'LINK_UNSUBSCRIBE',
|
||||
value: t('URL that points to the unsubscribe page')
|
||||
}, {
|
||||
key: 'LINK_PREFERENCES',
|
||||
value: t('URL that points to the preferences page of the subscriber')
|
||||
}, {
|
||||
key: 'LINK_BROWSER',
|
||||
value: t('URL to preview the message in a browser')
|
||||
}, {
|
||||
key: 'EMAIL',
|
||||
value: t('Email address')
|
||||
}, {
|
||||
key: 'SUBSCRIPTION_ID',
|
||||
value: t('Unique ID that identifies the recipient')
|
||||
}, {
|
||||
key: 'LIST_ID',
|
||||
value: t('Unique ID that identifies the list used for this campaign')
|
||||
}, {
|
||||
key: 'CAMPAIGN_ID',
|
||||
value: t('Unique ID that identifies current campaign')
|
||||
}];
|
||||
}
|
||||
|
||||
export function getRSSMergeTags(t) {
|
||||
return [{
|
||||
key: 'RSS_ENTRY',
|
||||
value: t('content from an RSS entry')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_TITLE',
|
||||
value: t('RSS entry title')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_DATE',
|
||||
value: t('RSS entry date')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_LINK',
|
||||
value: t('RSS entry link')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_CONTENT',
|
||||
value: t('content from an RSS entry')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_SUMMARY',
|
||||
value: t('RSS entry summary')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_IMAGE_URL',
|
||||
value: t('RSS entry image URL')
|
||||
}];
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ export class MosaicoEditor extends Component {
|
|||
return (
|
||||
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
|
||||
<div className={styles.navbar}>
|
||||
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('public/mailtrain-notext.png')}/>}
|
||||
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
|
||||
<div className={styles.title}>{this.props.title}</div>
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
|
||||
</div>
|
||||
|
@ -142,7 +142,7 @@ export class MosaicoSandbox extends Component {
|
|||
|
||||
plugins.unshift(vm => {
|
||||
// This is an override of the default paths in Mosaico
|
||||
vm.logoPath = getTrustedUrl('public/mosaico/img/mosaico32.png');
|
||||
vm.logoPath = getTrustedUrl('static/mosaico/img/mosaico32.png');
|
||||
vm.logoUrl = '#';
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
import {anonymousRestrictedAccessToken} from '../../../shared/urls';
|
||||
import {AppType} from '../../../shared/app';
|
||||
import mailtrainConfig from "mailtrainConfig";
|
||||
|
||||
let restrictedAccessToken = anonymousRestrictedAccessToken;
|
||||
|
@ -17,25 +18,34 @@ function getSandboxUrl(path) {
|
|||
return mailtrainConfig.sandboxUrlBase + restrictedAccessToken + '/' + (path || '');
|
||||
}
|
||||
|
||||
function getPublicUrl(path) {
|
||||
return mailtrainConfig.publicUrlBase + (path || '');
|
||||
}
|
||||
|
||||
function getUrl(path) {
|
||||
if (mailtrainConfig.trusted) {
|
||||
if (mailtrainConfig.appType === AppType.TRUSTED) {
|
||||
return getTrustedUrl(path);
|
||||
} else {
|
||||
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
|
||||
return getSandboxUrl(path);
|
||||
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
|
||||
return getPublicUrl(path);
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseDir() {
|
||||
if (mailtrainConfig.trusted) {
|
||||
if (mailtrainConfig.appType === AppType.TRUSTED) {
|
||||
return mailtrainConfig.trustedUrlBaseDir;
|
||||
} else {
|
||||
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
|
||||
return mailtrainConfig.sandboxUrlBaseDir + anonymousRestrictedAccessToken;
|
||||
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
|
||||
return mailtrainConfig.publicUrlBaseDir;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getTrustedUrl,
|
||||
getSandboxUrl,
|
||||
getPublicUrl,
|
||||
getUrl,
|
||||
getBaseDir,
|
||||
setRestrictedAccessToken
|
||||
|
|
|
@ -52,7 +52,8 @@ export default class CUD extends Component {
|
|||
contact_email: '',
|
||||
homepage: '',
|
||||
unsubscription_mode: UnsubscriptionMode.ONE_STEP,
|
||||
namespace: mailtrainConfig.user.namespace
|
||||
namespace: mailtrainConfig.user.namespace,
|
||||
to_name: '[FIRST_NAME] [LAST_NAME]'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -186,6 +187,7 @@ export default class CUD extends Component {
|
|||
|
||||
<InputField id="contact_email" label={t('Contact email')} help={t('Contact email used in subscription forms and emails that are sent out. If not filled in, the admin email from the global settings will be used.')}/>
|
||||
<InputField id="homepage" label={t('Homepage')} help={t('Homepage URL used in subscription forms and emails that are sent out. If not filled in, the default homepage from global settings will be used.')}/>
|
||||
<InputField id="to_name" label={t('Recipients name template')} help={t('Specify using merge tags of this list how to construct full name of the recipient. This full name is used as "To" header when sending emails.')}/>
|
||||
<TableSelect id="send_configuration" label={t('Send configuration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} help={t('Send configuration that will be used for sending out subscription-related emails.')}/>
|
||||
|
||||
<NamespaceSelect/>
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
import {Icon, Button} from "../../lib/bootstrap-components";
|
||||
import axios from '../../lib/axios';
|
||||
import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
|
||||
import {getUrl} from "../../lib/urls";
|
||||
import {getUrl, getPublicUrl} from "../../lib/urls";
|
||||
|
||||
@translate()
|
||||
@withForm
|
||||
|
@ -158,7 +158,7 @@ export default class List extends Component {
|
|||
return (
|
||||
<div>
|
||||
<Toolbar>
|
||||
<a href={`/subscription/${this.props.list.cid}`} className="btn-default"><Button label={t('Subscription Form')} className="btn-default"/></a>
|
||||
<a href={getPublicUrl(`subscription/${this.props.list.cid}`)} className="btn-default"><Button label={t('Subscription Form')} className="btn-default"/></a>
|
||||
<NavButton linkTo={`/lists/${this.props.list.id}/subscriptions/create`} className="btn-primary" icon="plus" label={t('Add Subscriber')}/>
|
||||
</Toolbar>
|
||||
|
||||
|
|
|
@ -134,7 +134,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
|
||||
templatePath={getSandboxUrl(`public/mosaico/templates/${owner.getFormValue(prefix + 'mosaicoFsTemplate')}/index.html`)}
|
||||
templatePath={getSandboxUrl(`static/mosaico/templates/${owner.getFormValue(prefix + 'mosaicoFsTemplate')}/index.html`)}
|
||||
entityTypeId={entityTypeId}
|
||||
title={t('Mosaico Template Designer')}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}/>
|
||||
|
@ -285,6 +285,14 @@ export function getEditForm(owner, typeKey, prefix = '') {
|
|||
<Trans>Email address</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[TO_NAME]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>Recipient name as it appears in email's 'To' header</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[SUBSCRIPTION_ID]
|
||||
|
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 434 KiB After Width: | Height: | Size: 434 KiB |
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 965 B |
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 215 B |
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 169 B |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 858 B |
Before Width: | Height: | Size: 876 B After Width: | Height: | Size: 876 B |
Before Width: | Height: | Size: 967 B After Width: | Height: | Size: 967 B |
Before Width: | Height: | Size: 996 B After Width: | Height: | Size: 996 B |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 939 B |
Before Width: | Height: | Size: 972 B After Width: | Height: | Size: 972 B |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |