merges 5.5 and master into next-back

This commit is contained in:
Daniël Klabbers 2017-12-14 01:00:16 +01:00
commit 2f97da972c
104 changed files with 1723 additions and 1828 deletions

View File

@ -2,7 +2,13 @@
---
> Try to complete the below form as far as you are able and are willing to share. Add a screenshot of the issue if you can.
## Bug report
## Explanation
Explain, in simple terms, but with as much detail as possible, your issue.
Be specific: What happened? What would you expect to happen? What have you tried so far?
## Technical details
- Version of Flarum: x.y.z
- Website URL where the bug is visible: http://example.com
- The webserver you are running: apache, nginx or something else
@ -16,9 +22,6 @@
Output of "php flarum info", run this in terminal in your Flarum directory.
```
## Additional comments
Some additional information you'd like to share, eg what have you tried so far.
## Log files
```

View File

@ -46,13 +46,14 @@
"nikic/fast-route": "^0.6",
"oyejorge/less.php": "~1.5",
"psr/http-message": "^1.0",
"symfony/config": "^3.3",
"symfony/console": "^3.3",
"symfony/http-foundation": "^3.3",
"symfony/translation": "^3.3",
"symfony/yaml": "^3.3",
"s9e/text-formatter": "^0.8.1",
"tobscure/json-api": "^0.3.0",
"zendframework/zend-diactoros": "^1.1",
"zendframework/zend-diactoros": "^1.6",
"zendframework/zend-stratigility": "^1.3"
},
"require-dev": {

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
</head>
<body>
<h1>403 Forbidden</h1>
<p>You do not have permissions to access this page.</p>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
</head>
<body>
<h1>404 Not Found</h1>
<p>Looks like this page could not be found.</p>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
</head>
<body>
<h1>500 Internal Server Error</h1>
<p>Something went wrong on our server.</p>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
</head>
<body>
<h1>503 Service Unavailable</h1>
<p>This forum is down for maintenance.</p>
</body>
</html>

View File

@ -17536,11 +17536,13 @@ System.register('flarum/components/AdminNav', ['flarum/Component', 'flarum/compo
babelHelpers.createClass(AdminNav, [{
key: 'view',
value: function view() {
return m(SelectDropdown, {
className: 'AdminNav App-titleControl',
buttonClassName: 'Button',
children: this.items().toArray()
});
return m(
SelectDropdown,
{
className: 'AdminNav App-titleControl',
buttonClassName: 'Button' },
this.items().toArray()
);
}
}, {
key: 'items',
@ -17832,8 +17834,8 @@ System.register('flarum/components/AppearancePage', ['flarum/components/Page', '
m(
'div',
{ className: 'AppearancePage-colors-input' },
m('input', { className: 'FormControl', type: 'color', placeholder: '#aaaaaa', value: this.primaryColor(), onchange: m.withAttr('value', this.primaryColor) }),
m('input', { className: 'FormControl', type: 'color', placeholder: '#aaaaaa', value: this.secondaryColor(), onchange: m.withAttr('value', this.secondaryColor) })
m('input', { className: 'FormControl', type: 'text', placeholder: '#aaaaaa', value: this.primaryColor(), onchange: m.withAttr('value', this.primaryColor) }),
m('input', { className: 'FormControl', type: 'text', placeholder: '#aaaaaa', value: this.secondaryColor(), onchange: m.withAttr('value', this.secondaryColor) })
),
Switch.component({
state: this.darkMode(),
@ -18356,15 +18358,17 @@ System.register('flarum/components/Checkbox', ['flarum/Component', 'flarum/compo
}
};
});;
"use strict";
'use strict';
System.register("flarum/components/DashboardPage", ["flarum/components/Page"], function (_export, _context) {
System.register('flarum/components/DashboardPage', ['flarum/components/Page', 'flarum/components/StatusWidget'], function (_export, _context) {
"use strict";
var Page, DashboardPage;
var Page, StatusWidget, DashboardPage;
return {
setters: [function (_flarumComponentsPage) {
Page = _flarumComponentsPage.default;
}, function (_flarumComponentsStatusWidget) {
StatusWidget = _flarumComponentsStatusWidget.default;
}],
execute: function () {
DashboardPage = function (_Page) {
@ -18376,70 +18380,74 @@ System.register("flarum/components/DashboardPage", ["flarum/components/Page"], f
}
babelHelpers.createClass(DashboardPage, [{
key: "view",
key: 'view',
value: function view() {
return m(
"div",
{ className: "DashboardPage" },
'div',
{ className: 'DashboardPage' },
m(
"div",
{ className: "container" },
m(
"h2",
null,
app.translator.trans('core.admin.dashboard.welcome_text')
),
m(
"p",
null,
app.translator.trans('core.admin.dashboard.version_text', { version: m(
"strong",
null,
app.forum.attribute('version')
) })
),
m(
"p",
null,
app.translator.trans('core.admin.dashboard.beta_warning_text', { strong: m("strong", null) })
),
m(
"ul",
null,
m(
"li",
null,
app.translator.trans('core.admin.dashboard.contributing_text', { a: m("a", { href: "http://flarum.org/docs/contributing", target: "_blank" }) })
),
m(
"li",
null,
app.translator.trans('core.admin.dashboard.troubleshooting_text', { a: m("a", { href: "http://flarum.org/docs/troubleshooting", target: "_blank" }) })
),
m(
"li",
null,
app.translator.trans('core.admin.dashboard.support_text', { a: m("a", { href: "http://discuss.flarum.org/t/support", target: "_blank" }) })
),
m(
"li",
null,
app.translator.trans('core.admin.dashboard.features_text', { a: m("a", { href: "http://discuss.flarum.org/t/features", target: "_blank" }) })
),
m(
"li",
null,
app.translator.trans('core.admin.dashboard.extension_text', { a: m("a", { href: "http://flarum.org/docs/extend", target: "_blank" }) })
)
)
'div',
{ className: 'container' },
this.availableWidgets()
)
);
}
}, {
key: 'availableWidgets',
value: function availableWidgets() {
return [m(StatusWidget, null)];
}
}]);
return DashboardPage;
}(Page);
_export("default", DashboardPage);
_export('default', DashboardPage);
}
};
});;
'use strict';
System.register('flarum/components/DashboardWidget', ['flarum/Component'], function (_export, _context) {
"use strict";
var Component, Widget;
return {
setters: [function (_flarumComponent) {
Component = _flarumComponent.default;
}],
execute: function () {
Widget = function (_Component) {
babelHelpers.inherits(Widget, _Component);
function Widget() {
babelHelpers.classCallCheck(this, Widget);
return babelHelpers.possibleConstructorReturn(this, (Widget.__proto__ || Object.getPrototypeOf(Widget)).apply(this, arguments));
}
babelHelpers.createClass(Widget, [{
key: 'view',
value: function view() {
return m(
'div',
{ className: "Widget " + this.className() },
this.content()
);
}
}, {
key: 'className',
value: function className() {
return '';
}
}, {
key: 'content',
value: function content() {
return [];
}
}]);
return Widget;
}(Component);
_export('default', Widget);
}
};
});;
@ -18509,6 +18517,10 @@ System.register('flarum/components/Dropdown', ['flarum/Component', 'flarum/helpe
$menu.toggleClass('Dropdown-menu--top', $menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height());
if ($menu.offset().top < 0) {
$menu.removeClass('Dropdown-menu--top');
}
$menu.toggleClass('Dropdown-menu--right', isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width());
});
@ -21102,6 +21114,84 @@ System.register('flarum/components/SplitDropdown', ['flarum/components/Dropdown'
});;
'use strict';
System.register('flarum/components/StatusWidget', ['flarum/components/DashboardWidget', 'flarum/helpers/icon', 'flarum/helpers/listItems', 'flarum/utils/ItemList'], function (_export, _context) {
"use strict";
var DashboardWidget, icon, listItems, ItemList, StatusWidget;
return {
setters: [function (_flarumComponentsDashboardWidget) {
DashboardWidget = _flarumComponentsDashboardWidget.default;
}, function (_flarumHelpersIcon) {
icon = _flarumHelpersIcon.default;
}, function (_flarumHelpersListItems) {
listItems = _flarumHelpersListItems.default;
}, function (_flarumUtilsItemList) {
ItemList = _flarumUtilsItemList.default;
}],
execute: function () {
StatusWidget = function (_DashboardWidget) {
babelHelpers.inherits(StatusWidget, _DashboardWidget);
function StatusWidget() {
babelHelpers.classCallCheck(this, StatusWidget);
return babelHelpers.possibleConstructorReturn(this, (StatusWidget.__proto__ || Object.getPrototypeOf(StatusWidget)).apply(this, arguments));
}
babelHelpers.createClass(StatusWidget, [{
key: 'className',
value: function className() {
return 'StatusWidget';
}
}, {
key: 'content',
value: function content() {
return m(
'ul',
null,
listItems(this.items().toArray())
);
}
}, {
key: 'items',
value: function items() {
var items = new ItemList();
items.add('help', m(
'a',
{ href: 'http://flarum.org/docs/troubleshooting', target: '_blank' },
icon('question-circle'),
' ',
app.translator.trans('core.admin.dashboard.help_link')
));
items.add('version-flarum', [m(
'strong',
null,
'Flarum'
), m('br', null), app.forum.attribute('version')]);
items.add('version-php', [m(
'strong',
null,
'PHP'
), m('br', null), app.data.phpVersion]);
items.add('version-mysql', [m(
'strong',
null,
'MySQL'
), m('br', null), app.data.mysqlVersion]);
return items;
}
}]);
return StatusWidget;
}(DashboardWidget);
_export('default', StatusWidget);
}
};
});;
'use strict';
System.register('flarum/components/Switch', ['flarum/components/Checkbox'], function (_export, _context) {
"use strict";
@ -21255,6 +21345,52 @@ System.register('flarum/components/UploadImageButton', ['flarum/components/Butto
}
};
});;
'use strict';
System.register('flarum/components/Widget', ['flarum/Component'], function (_export, _context) {
"use strict";
var Component, DashboardWidget;
return {
setters: [function (_flarumComponent) {
Component = _flarumComponent.default;
}],
execute: function () {
DashboardWidget = function (_Component) {
babelHelpers.inherits(DashboardWidget, _Component);
function DashboardWidget() {
babelHelpers.classCallCheck(this, DashboardWidget);
return babelHelpers.possibleConstructorReturn(this, (DashboardWidget.__proto__ || Object.getPrototypeOf(DashboardWidget)).apply(this, arguments));
}
babelHelpers.createClass(DashboardWidget, [{
key: 'view',
value: function view() {
return m(
'div',
{ className: "DashboardWidget " + this.className() },
this.content()
);
}
}, {
key: 'className',
value: function className() {
return '';
}
}, {
key: 'content',
value: function content() {
return [];
}
}]);
return DashboardWidget;
}(Component);
_export('default', DashboardWidget);
}
};
});;
"use strict";
System.register("flarum/extend", [], function (_export, _context) {
@ -21364,7 +21500,7 @@ System.register('flarum/helpers/avatar', [], function (_export, _context) {
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
var username = user.username() || '?';
var username = user.displayName() || '?';
var avatarUrl = user.avatarUrl();
if (hasTitle) attrs.title = attrs.title || username;
@ -21614,7 +21750,7 @@ System.register("flarum/helpers/username", [], function (_export, _context) {
"use strict";
function username(user) {
var name = user && user.username() || app.translator.trans('core.lib.username.deleted_text');
var name = user && user.displayName() || app.translator.trans('core.lib.username.deleted_text');
return m(
"span",
@ -22445,15 +22581,12 @@ System.register('flarum/models/User', ['flarum/Model', 'flarum/utils/stringToCol
babelHelpers.extends(User.prototype, {
username: Model.attribute('username'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isActivated: Model.attribute('isActivated'),
password: Model.attribute('password'),
avatarUrl: Model.attribute('avatarUrl'),
bio: Model.attribute('bio'),
bioHtml: computed('bio', function (bio) {
return bio ? '<p>' + $('<div/>').text(bio).html().replace(/\n/g, '<br>').autoLink({ rel: 'nofollow' }) + '</p>' : '';
}),
preferences: Model.attribute('preferences'),
groups: Model.hasMany('groups'),
@ -23316,7 +23449,7 @@ System.register('flarum/utils/extractText', [], function (_export, _context) {
return vdom.map(function (element) {
return extractText(element);
}).join('');
} else if ((typeof vdom === 'undefined' ? 'undefined' : babelHelpers.typeof(vdom)) === 'object') {
} else if ((typeof vdom === 'undefined' ? 'undefined' : babelHelpers.typeof(vdom)) === 'object' && vdom !== null) {
return extractText(vdom.children);
} else {
return vdom;
@ -23602,7 +23735,12 @@ System.register('flarum/utils/patchMithril', ['../Component'], function (_export
}
if (comp.prototype && comp.prototype instanceof Component) {
return comp.component.apply(comp, args);
var children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
}
return comp.component(args[0], children);
}
var node = mo.apply(this, arguments);

View File

@ -18,9 +18,9 @@ export default class AdminNav extends Component {
return (
<SelectDropdown
className="AdminNav App-titleControl"
buttonClassName="Button"
children={this.items().toArray()}
/>
buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}

View File

@ -28,8 +28,8 @@ export default class AppearancePage extends Page {
</div>
<div className="AppearancePage-colors-input">
<input className="FormControl" type="color" placeholder="#aaaaaa" value={this.primaryColor()} onchange={m.withAttr('value', this.primaryColor)}/>
<input className="FormControl" type="color" placeholder="#aaaaaa" value={this.secondaryColor()} onchange={m.withAttr('value', this.secondaryColor)}/>
<input className="FormControl" type="text" placeholder="#aaaaaa" value={this.primaryColor()} onchange={m.withAttr('value', this.primaryColor)}/>
<input className="FormControl" type="text" placeholder="#aaaaaa" value={this.secondaryColor()} onchange={m.withAttr('value', this.secondaryColor)}/>
</div>
{Switch.component({

View File

@ -1,22 +1,18 @@
import Page from 'flarum/components/Page';
import StatusWidget from 'flarum/components/StatusWidget';
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<div className="container">
<h2>{app.translator.trans('core.admin.dashboard.welcome_text')}</h2>
<p>{app.translator.trans('core.admin.dashboard.version_text', {version: <strong>{app.forum.attribute('version')}</strong>})}</p>
<p>{app.translator.trans('core.admin.dashboard.beta_warning_text', {strong: <strong/>})}</p>
<ul>
<li>{app.translator.trans('core.admin.dashboard.contributing_text', {a: <a href="http://flarum.org/docs/contributing" target="_blank"/>})}</li>
<li>{app.translator.trans('core.admin.dashboard.troubleshooting_text', {a: <a href="http://flarum.org/docs/troubleshooting" target="_blank"/>})}</li>
<li>{app.translator.trans('core.admin.dashboard.support_text', {a: <a href="http://discuss.flarum.org/t/support" target="_blank"/>})}</li>
<li>{app.translator.trans('core.admin.dashboard.features_text', {a: <a href="http://discuss.flarum.org/t/features" target="_blank"/>})}</li>
<li>{app.translator.trans('core.admin.dashboard.extension_text', {a: <a href="http://flarum.org/docs/extend" target="_blank"/>})}</li>
</ul>
{this.availableWidgets()}
</div>
</div>
);
}
availableWidgets() {
return [<StatusWidget/>];
}
}

View File

@ -0,0 +1,38 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from 'flarum/Component';
export default class Widget extends Component {
view() {
return (
<div className={"Widget "+this.className()}>
{this.content()}
</div>
);
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
content() {
return [];
}
}

View File

@ -0,0 +1,41 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import DashboardWidget from 'flarum/components/DashboardWidget';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/listItems';
import ItemList from 'flarum/utils/ItemList';
export default class StatusWidget extends DashboardWidget {
className() {
return 'StatusWidget';
}
content() {
return (
<ul>{listItems(this.items().toArray())}</ul>
);
}
items() {
const items = new ItemList();
items.add('help', (
<a href="http://flarum.org/docs/troubleshooting" target="_blank">
{icon('question-circle')} {app.translator.trans('core.admin.dashboard.help_link')}
</a>
));
items.add('version-flarum', [<strong>Flarum</strong>, <br/>, app.forum.attribute('version')]);
items.add('version-php', [<strong>PHP</strong>, <br/>, app.data.phpVersion]);
items.add('version-mysql', [<strong>MySQL</strong>, <br/>, app.data.mysqlVersion]);
return items;
}
}

View File

@ -0,0 +1,38 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from 'flarum/Component';
export default class DashboardWidget extends Component {
view() {
return (
<div className={"DashboardWidget "+this.className()}>
{this.content()}
</div>
);
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
content() {
return [];
}
}

View File

@ -9,8 +9,6 @@
"color-thief": "v2.0",
"mithril": "lhorie/mithril.js#v0.2.5",
"es6-micro-loader": "caridy/es6-micro-loader#v0.2.1",
"fastclick": "~1.0.6",
"autolink": "~1.0.0",
"m.attrs.bidi": "tobscure/m.attrs.bidi",
"punycode": "http://cdnjs.cloudflare.com/ajax/libs/punycode/1.4.1/punycode.js"
}

View File

@ -13,7 +13,6 @@ gulp({
bowerDir + '/jquery.hotkeys/jquery.hotkeys.js',
bowerDir + '/color-thief/src/color-thief.js',
bowerDir + '/moment/moment.js',
bowerDir + '/autolink/autolink-min.js',
bowerDir + '/bootstrap/js/affix.js',
bowerDir + '/bootstrap/js/dropdown.js',
@ -23,7 +22,6 @@ gulp({
bowerDir + '/spin.js/spin.js',
bowerDir + '/spin.js/jquery.spin.js',
bowerDir + '/fastclick/lib/fastclick.js',
bowerDir + '/punycode/index.js'
],
modules: {

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,13 @@ export default class AvatarEditor extends Component {
* @type {Boolean}
*/
this.loading = false;
/**
* Whether or not an image has been dragged over the dropzone.
*
* @type {Boolean}
*/
this.isDraggedOver = false;
}
static initProps(props) {
@ -35,12 +42,17 @@ export default class AvatarEditor extends Component {
const user = this.props.user;
return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '')}>
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
{avatar(user)}
<a className={ user.avatarUrl() ? "Dropdown-toggle" : "Dropdown-toggle AvatarEditor--noAvatar" }
title={app.translator.trans('core.forum.user.avatar_upload_tooltip')}
data-toggle="dropdown"
onclick={this.quickUpload.bind(this)}>
onclick={this.quickUpload.bind(this)}
ondragover={this.enableDragover.bind(this)}
ondragenter={this.enableDragover.bind(this)}
ondragleave={this.disableDragover.bind(this)}
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}>
{this.loading ? LoadingIndicator.component() : (user.avatarUrl() ? icon('pencil') : icon('plus-circle'))}
</a>
<ul className="Dropdown-menu Menu">
@ -62,7 +74,7 @@ export default class AvatarEditor extends Component {
Button.component({
icon: 'upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.upload.bind(this)
onclick: this.openPicker.bind(this)
})
);
@ -77,6 +89,40 @@ export default class AvatarEditor extends Component {
return items;
}
/**
* Enable dragover style
*
* @param {Event} e
*/
enableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = true;
}
/**
* Disable dragover style
*
* @param {Event} e
*/
disableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
}
/**
* Upload avatar when file is dropped into dropzone.
*
* @param {Event} e
*/
dropUpload(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
this.upload(e.dataTransfer.files[0]);
}
/**
* If the user doesn't have an avatar, there's no point in showing the
* controls dropdown, because only one option would be viable: uploading.
@ -89,14 +135,14 @@ export default class AvatarEditor extends Component {
if (!this.props.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.upload();
this.openPicker();
}
}
/**
* Prompt the user to upload a new avatar.
* Upload avatar using file picker
*/
upload() {
openPicker() {
if (this.loading) return;
// Create a hidden HTML input element and click on it so the user can select
@ -105,24 +151,36 @@ export default class AvatarEditor extends Component {
const $input = $('<input type="file">');
$input.appendTo('body').hide().click().on('change', e => {
const data = new FormData();
data.append('avatar', $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
serialize: raw => raw,
data
}).then(
this.success.bind(this),
this.failure.bind(this)
);
this.upload($(e.target)[0].files[0]);
});
}
/**
* Upload avatar
*
* @param {File} file
*/
upload(file) {
if (this.loading) return;
const user = this.props.user;
const data = new FormData();
data.append('avatar', file);
this.loading = true;
m.redraw();
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
serialize: raw => raw,
data
}).then(
this.success.bind(this),
this.failure.bind(this)
);
}
/**
* Remove the user's avatar.
*/

View File

@ -91,6 +91,10 @@ class Composer extends Component {
defaultHeight = this.$().height();
}
// Set the height of the Composer element and its contents on each redraw,
// so that they do not lose it if their DOM elements are recreated.
this.updateHeight();
if (isInitialized) return;
// Since this component is a part of the global UI that persists between

View File

@ -16,39 +16,17 @@ export default class NotificationList extends Component {
* @type {Boolean}
*/
this.loading = false;
/**
* Whether or not there are more results that can be loaded.
*
* @type {Boolean}
*/
this.moreResults = false;
}
view() {
const groups = [];
if (app.cache.notifications) {
const discussions = {};
// Build an array of discussions which the notifications are related to,
// and add the notifications as children.
app.cache.notifications.forEach(notification => {
const subject = notification.subject();
if (typeof subject === 'undefined') return;
// Get the discussion that this notification is related to. If it's not
// directly related to a discussion, it may be related to a post or
// other entity which is related to a discussion.
let discussion = false;
if (subject instanceof Discussion) discussion = subject;
else if (subject && subject.discussion) discussion = subject.discussion();
// If the notification is not related to a discussion directly or
// indirectly, then we will assign it to a neutral group.
const key = discussion ? discussion.id() : 0;
discussions[key] = discussions[key] || {discussion: discussion, notifications: []};
discussions[key].notifications.push(notification);
if (groups.indexOf(discussions[key]) === -1) {
groups.push(discussions[key]);
}
});
}
const pages = app.cache.notifications || [];
return (
<div className="NotificationList">
@ -66,8 +44,34 @@ export default class NotificationList extends Component {
</div>
<div className="NotificationList-content">
{groups.length
? groups.map(group => {
{pages.length ? pages.map(notifications => {
const groups = [];
const discussions = {};
notifications.forEach(notification => {
const subject = notification.subject();
if (typeof subject === 'undefined') return;
// Get the discussion that this notification is related to. If it's not
// directly related to a discussion, it may be related to a post or
// other entity which is related to a discussion.
let discussion = false;
if (subject instanceof Discussion) discussion = subject;
else if (subject && subject.discussion) discussion = subject.discussion();
// If the notification is not related to a discussion directly or
// indirectly, then we will assign it to a neutral group.
const key = discussion ? discussion.id() : 0;
discussions[key] = discussions[key] || {discussion: discussion, notifications: []};
discussions[key].notifications.push(notification);
if (groups.indexOf(discussions[key]) === -1) {
groups.push(discussions[key]);
}
});
return groups.map(group => {
const badges = group.discussion && group.discussion.badges().toArray();
return (
@ -94,32 +98,71 @@ export default class NotificationList extends Component {
</ul>
</div>
);
})
: !this.loading
? <div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>
: LoadingIndicator.component({className: 'LoadingIndicator--block'})}
});
}) : ''}
{this.loading
? <LoadingIndicator className="LoadingIndicator--block" />
: (pages.length ? '' : <div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>)}
</div>
</div>
);
}
config(isInitialized, context) {
if (isInitialized) return;
const $notifications = this.$('.NotificationList-content');
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
const scrollHandler = () => {
const scrollTop = $scrollParent.scrollTop();
const viewportHeight = $scrollParent.height();
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
const contentHeight = $notifications[0].scrollHeight;
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.loadMore();
}
};
$scrollParent.on('scroll', scrollHandler);
context.onunload = () => {
$scrollParent.off('scroll', scrollHandler);
};
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (app.cache.notifications && !app.session.user.newNotificationsCount()) {
if (app.session.user.newNotificationsCount()) {
delete app.cache.notifications;
}
if (app.cache.notifications) {
return;
}
app.session.user.pushAttributes({newNotificationsCount: 0});
this.loadMore();
}
/**
* Load the next page of notification results.
*
* @public
*/
loadMore() {
this.loading = true;
m.redraw();
app.store.find('notifications')
.then(notifications => {
app.session.user.pushAttributes({newNotificationsCount: 0});
app.cache.notifications = notifications.sort((a, b) => b.time() - a.time());
})
const params = app.cache.notifications ? {page: {offset: app.cache.notifications.length * 10}} : null;
return app.store.find('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
@ -127,6 +170,21 @@ export default class NotificationList extends Component {
});
}
/**
* Parse results and append them to the notification list.
*
* @param {Notification[]} results
* @return {Notification[]}
*/
parseResults(results) {
app.cache.notifications = app.cache.notifications || [];
app.cache.notifications.push(results);
this.moreResults = !!results.payload.links.next;
return results;
}
/**
* Mark all of the notifications as read.
*/
@ -135,7 +193,9 @@ export default class NotificationList extends Component {
app.session.user.pushAttributes({unreadNotificationsCount: 0});
app.cache.notifications.forEach(notification => notification.pushAttributes({isRead: true}));
app.cache.notifications.forEach(notifications => {
notifications.forEach(notification => notification.pushAttributes({isRead: true}))
});
app.request({
url: app.forum.attribute('apiUrl') + '/notifications/read',

View File

@ -126,7 +126,7 @@ class PostStream extends Component {
this.visibleEnd = this.count();
this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
}
/**

View File

@ -82,9 +82,10 @@ export default class ReplyComposer extends ComposerBody {
app.store.createRecord('posts').save(data).then(
post => {
// If we're currently viewing the discussion which this reply was made
// in, then we can update the post stream.
// in, then we can update the post stream and scroll to the post.
if (app.viewingDiscussion(discussion)) {
app.current.stream.update();
app.current.stream.update().then(() => app.current.stream.goToNumber(post.number()));
} else {
// Otherwise, we'll create an alert message to inform the user that
// their reply has been posted, containing a button which will

View File

@ -82,7 +82,8 @@ export default class TextEditor extends Component {
Button.component({
icon: 'eye',
className: 'Button Button--icon',
onclick: this.props.preview
onclick: this.props.preview,
title: app.translator.trans('core.forum.composer.preview_tooltip')
})
);
}

View File

@ -1,104 +0,0 @@
import Component from 'flarum/Component';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import classList from 'flarum/utils/classList';
import extractText from 'flarum/utils/extractText';
/**
* The `UserBio` component displays a user's bio, optionally letting the user
* edit it.
*/
export default class UserBio extends Component {
init() {
/**
* Whether or not the bio is currently being edited.
*
* @type {Boolean}
*/
this.editing = false;
/**
* Whether or not the bio is currently being saved.
*
* @type {Boolean}
*/
this.loading = false;
}
view() {
const user = this.props.user;
let content;
if (this.editing) {
content = <textarea className="FormControl" placeholder={extractText(app.translator.trans('core.forum.user.bio_placeholder'))} rows="3" value={user.bio()}/>;
} else {
let subContent;
if (this.loading) {
subContent = <p className="UserBio-placeholder">{LoadingIndicator.component({size: 'tiny'})}</p>;
} else {
const bioHtml = user.bioHtml();
if (bioHtml) {
subContent = m.trust(bioHtml);
} else if (this.props.editable) {
subContent = <p className="UserBio-placeholder">{app.translator.trans('core.forum.user.bio_placeholder')}</p>;
}
}
content = <div className="UserBio-content" onclick={this.edit.bind(this)}>{subContent}</div>;
}
return (
<div className={'UserBio ' + classList({
editable: this.props.editable,
editing: this.editing
})}>
{content}
</div>
);
}
/**
* Edit the bio.
*/
edit() {
if (!this.props.editable) return;
this.editing = true;
m.redraw();
const bio = this;
const save = function(e) {
if (e.shiftKey) return;
e.preventDefault();
bio.save($(this).val());
};
this.$('textarea').focus()
.bind('blur', save)
.bind('keydown', 'return', save);
}
/**
* Save the bio.
*
* @param {String} value
*/
save(value) {
const user = this.props.user;
if (user.bio() !== value) {
this.loading = true;
user.save({bio: value})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
this.editing = false;
m.redraw();
}
}

View File

@ -6,7 +6,6 @@ import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import icon from 'flarum/helpers/icon';
import Dropdown from 'flarum/components/Dropdown';
import UserBio from 'flarum/components/UserBio';
import AvatarEditor from 'flarum/components/AvatarEditor';
import listItems from 'flarum/helpers/listItems';
@ -82,13 +81,6 @@ export default class UserCard extends Component {
const user = this.props.user;
const lastSeenTime = user.lastSeenTime();
items.add('bio',
UserBio.component({
user,
editable: this.props.editable
})
);
if (lastSeenTime) {
const online = user.isOnline();

View File

@ -72,7 +72,7 @@ export default class UserPage extends Page {
show(user) {
this.user = user;
app.setTitle(user.username());
app.setTitle(user.displayName());
m.redraw();
}

View File

@ -9,18 +9,27 @@ import username from 'flarum/helpers/username';
* @implements SearchSource
*/
export default class UsersSearchResults {
constructor() {
this.results = {};
}
search(query) {
return app.store.find('users', {
filter: {q: query},
page: {limit: 5}
}).then(results => {
this.results[query] = results;
m.redraw();
});
}
view(query) {
query = query.toLowerCase();
const results = app.store.all('users')
.filter(user => user.username().toLowerCase().substr(0, query.length) === query);
const results = (this.results[query] || [])
.concat(app.store.all('users').filter(user => [user.username(), user.displayName()].some(value => value.toLowerCase().substr(0, query.length) === query)))
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
.sort((a, b) => a.displayName().localeCompare(b.displayName()));
if (!results.length) return '';

View File

@ -78,11 +78,7 @@ export default function boot(app) {
.toggleClass('scrolled', top > offset);
}).start();
// Initialize FastClick, which makes links and buttons much more responsive on
// touch devices.
$(() => {
FastClick.attach(document.body);
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});

View File

@ -162,7 +162,7 @@ export default {
}
app.composer.show();
if (goToLast && app.viewingDiscussion(this)) {
if (goToLast && app.viewingDiscussion(this) && ! app.composer.isFullScreen()) {
app.current.stream.goToNumber('reply');
}

View File

@ -69,6 +69,10 @@ export default class Dropdown extends Component {
$menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height()
);
if ($menu.offset().top < 0) {
$menu.removeClass('Dropdown-menu--top');
}
$menu.toggleClass(
'Dropdown-menu--right',
isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width()

View File

@ -19,7 +19,7 @@ export default function avatar(user, attrs = {}) {
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
const username = user.username() || '?';
const username = user.displayName() || '?';
const avatarUrl = user.avatarUrl();
if (hasTitle) attrs.title = attrs.title || username;

View File

@ -6,7 +6,7 @@
* @return {Object}
*/
export default function username(user) {
const name = (user && user.username()) || app.translator.trans('core.lib.username.deleted_text');
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
return <span className="username">{name}</span>;
}

View File

@ -10,13 +10,12 @@ export default class User extends Model {}
Object.assign(User.prototype, {
username: Model.attribute('username'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isActivated: Model.attribute('isActivated'),
password: Model.attribute('password'),
avatarUrl: Model.attribute('avatarUrl'),
bio: Model.attribute('bio'),
bioHtml: computed('bio', bio => bio ? '<p>' + $('<div/>').text(bio).html().replace(/\n/g, '<br>').autoLink({rel: 'nofollow'}) + '</p>' : ''),
preferences: Model.attribute('preferences'),
groups: Model.hasMany('groups'),

View File

@ -7,7 +7,7 @@
export default function extractText(vdom) {
if (vdom instanceof Array) {
return vdom.map(element => extractText(element)).join('');
} else if (typeof vdom === 'object') {
} else if (typeof vdom === 'object' && vdom !== null) {
return extractText(vdom.children);
} else {
return vdom;

View File

@ -5,7 +5,12 @@ export default function patchMithril(global) {
const m = function(comp, ...args) {
if (comp.prototype && comp.prototype instanceof Component) {
return comp.component(...args);
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0]
}
return comp.component(args[0], children);
}
const node = mo.apply(this, arguments);

View File

@ -2,20 +2,46 @@
background: @control-bg;
color: @control-color;
min-height: 100vh;
font-size: 14px;
line-height: 1.7;
@media @desktop-up {
.container {
max-width: 600px;
padding: 30px;
margin: 0;
}
}
}
h2 {
font-size: 26px;
font-weight: 300;
margin-top: 0;
.Widget {
background: @body-bg;
color: @text-color;
border-radius: @border-radius;
padding: 20px;
margin-bottom: 20px;
}
.StatusWidget {
color: @muted-color;
> ul {
margin: 0;
padding: 0;
list-style-type: none;
> li {
display: inline-block;
margin-right: 30px;
vertical-align: middle;
&[class^="item-version-"] {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.item-help {
float: right;
margin-right: 0;
}
}
}
}

View File

@ -17,7 +17,10 @@
.AvatarEditor--noAvatar {
opacity: 0.7;
}
&:hover .Dropdown-toggle, &.open .Dropdown-toggle, &.loading .Dropdown-toggle {
&:hover .Dropdown-toggle,
&.open .Dropdown-toggle,
&.loading .Dropdown-toggle,
&.dragover .Dropdown-toggle {
opacity: 1;
}
.LoadingIndicator {

View File

@ -88,14 +88,11 @@
right: 0;
bottom: 0;
background: fade(@body-bg, 90%);
opacity: 0;
pointer-events: none;
display: none;
border-radius: @border-radius @border-radius 0 0;
.transition(opacity 0.2s);
&.active {
opacity: 1;
pointer-events: auto;
display: block;
}
}
.ComposerBody-editor {
@ -119,7 +116,7 @@
&:not(.minimized) {
position: absolute;
top: 0;
height: 100vh !important;
height: 350px !important;
padding-top: @header-height-phone;
&:before {

View File

@ -6,7 +6,6 @@
.NotificationList-content {
max-height: 70vh;
overflow: auto;
padding-bottom: 10px;
}
}
& .Dropdown-toggle .Button-label {

View File

@ -90,37 +90,6 @@
display: inline-block;
margin-right: 15px;
}
.item-bio {
display: block;
margin: 0;
}
}
.UserBio {
margin: -10px -10px 10px;
border: 1px dashed transparent;
border-radius: @border-radius;
&.editable:not(.editing) {
cursor: text;
&:hover {
border-color: rgba(255, 255, 255, 0.2);
}
}
&, textarea {
font-size: 14px;
}
textarea {
padding: 10px;
font-size: 14px;
resize: none;
}
}
.UserBio-content {
padding: 10px 10px 1px;
}
.UserBio-placeholder {
opacity: 0.3;
}
.UserCard-lastSeen {
& .icon {

View File

@ -171,6 +171,8 @@
.Header-logo {
max-height: 30px;
vertical-align: middle;
// Prevent blurriness in Chrome
image-rendering: -webkit-optimize-contrast;
}
// On phones, the header is displayed inside of the drawer. We lay its

View File

@ -14,6 +14,8 @@
height: 100%;
border-radius: 100%;
vertical-align: top;
// Prevent blurriness in Chrome
image-rendering: -webkit-optimize-contrast;
}
}

View File

@ -9,7 +9,7 @@
// control; the new discussion button is the primary-control. On anything
// larger than a phone, however, we need to affix the sidebar and expand the
// .dropdown-select into a plain list.
@media @tablet-up {
@media @expand-side-nav {
.sideNav {
// Expand the dropdown-select component into a normal nav list.
& .Dropdown--select {
@ -48,45 +48,50 @@
}
}
.sideNav--horizontal {
padding: 15px 0;
white-space: nowrap;
overflow: auto;
-webkit-overflow-scrolling: touch;
.sideNav--horizontal {}
&:after {
content: " ";
position: absolute;
left: 0;
right: 0;
margin-top: 15px;
border-bottom: 1px solid @control-bg;
}
@media @expand-side-nav {
.sideNav--horizontal {
padding: 15px 0;
white-space: nowrap;
overflow: auto;
-webkit-overflow-scrolling: touch;
> ul > li, .Dropdown-menu > li {
display: inline-block;
margin: 0 20px 0 0;
vertical-align: top;
}
.Dropdown-separator {
display: none;
}
&:after {
content: " ";
position: absolute;
left: 0;
right: 0;
margin-top: 15px;
border-bottom: 1px solid @control-bg;
}
.Dropdown--select .Dropdown-menu > li > a {
padding-left: 25px;
> ul > li, .Dropdown-menu > li {
display: inline-block;
margin: 0 20px 0 0;
vertical-align: top;
}
.Dropdown-separator {
display: none;
}
.icon {
margin-left: -25px;
.Dropdown--select .Dropdown-menu > li > a {
padding-left: 25px;
.icon {
margin-left: -25px;
}
}
.affix {
position: static;
}
}
.affix {
position: static;
}
}
@media @tablet {
.sideNav {
.sideNav--horizontal();
@media @tablet {
.sideNav {
.sideNav--horizontal();
}
}
}

View File

@ -110,6 +110,8 @@
@zindex-alerts: 1060;
@zindex-tooltip: 1070;
@expand-side-nav: @tablet-up;
// ---------------------------------
// BREAKPOINTS

View File

@ -15,6 +15,10 @@ use Flarum\Admin\Middleware\RequireAdministrateAbility;
use Flarum\Event\ConfigureMiddleware;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Core\Listener\CheckCustomLessFormat;
use Flarum\Event\ExtensionWasDisabled;
use Flarum\Event\ExtensionWasEnabled;
use Flarum\Event\SettingWasSet;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Http\Middleware\AuthenticateWithSession;
use Flarum\Http\Middleware\DispatchRoute;
@ -80,6 +84,8 @@ class AdminServiceProvider extends AbstractServiceProvider
$this->flushWebAppAssetsWhenThemeChanged();
$this->flushWebAppAssetsWhenExtensionsChanged();
$this->checkCustomLessFormat();
}
/**
@ -124,4 +130,11 @@ class AdminServiceProvider extends AbstractServiceProvider
{
return $this->app->make(Frontend::class)->getAssets();
}
protected function checkCustomLessFormat()
{
$events = $this->app->make('events');
$events->subscribe(CheckCustomLessFormat::class);
}
}

View File

@ -18,6 +18,7 @@ use Flarum\Group\Permission;
use Flarum\Settings\Event\Deserializing;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionInterface;
use Psr\Http\Message\ServerRequestInterface;
class FrontendController extends AbstractFrontendController
@ -32,18 +33,25 @@ class FrontendController extends AbstractFrontendController
*/
protected $extensions;
/**
* @var ConnectionInterface
*/
protected $db;
/**
* @param Frontend $webApp
* @param Dispatcher $events
* @param SettingsRepositoryInterface $settings
* @param ExtensionManager $extensions
* @param ConnectionInterface $db
*/
public function __construct(Frontend $webApp, Dispatcher $events, SettingsRepositoryInterface $settings, ExtensionManager $extensions)
public function __construct(Frontend $webApp, Dispatcher $events, SettingsRepositoryInterface $settings, ExtensionManager $extensions, ConnectionInterface $db)
{
$this->webApp = $webApp;
$this->events = $events;
$this->settings = $settings;
$this->extensions = $extensions;
$this->db = $db;
}
/**
@ -63,6 +71,9 @@ class FrontendController extends AbstractFrontendController
$view->setVariable('permissions', Permission::map());
$view->setVariable('extensions', $this->extensions->getExtensions()->toArray());
$view->setVariable('phpVersion', PHP_VERSION);
$view->setVariable('mysqlVersion', $this->db->selectOne('select version() as version')->version);
return $view;
}
}

View File

@ -93,6 +93,18 @@ class ListDiscussionsController extends AbstractListController
$results->areMoreResults() ? null : 0
);
return $results->getResults();
$results = $results->getResults();
if ($relations = array_intersect($load, ['startPost', 'lastPost'])) {
foreach ($results as $discussion) {
foreach ($relations as $relation) {
if ($discussion->$relation) {
$discussion->$relation->discussion = $discussion;
}
}
}
}
return $results;
}
}

View File

@ -14,6 +14,7 @@ namespace Flarum\Api\Controller;
use Flarum\Discussion\Discussion;
use Flarum\Notification\NotificationRepository;
use Flarum\User\Exception\PermissionDeniedException;
use Flarum\Api\UrlGenerator;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
@ -39,16 +40,23 @@ class ListNotificationsController extends AbstractListController
public $limit = 10;
/**
* @var \Flarum\Notification\NotificationRepository
* @var NotificationRepository
*/
protected $notifications;
/**
* @param \Flarum\Notification\NotificationRepository $notifications
* @var UrlGenerator
*/
public function __construct(NotificationRepository $notifications)
protected $url;
/**
* @param NotificationRepository $notifications
* @param UrlGenerator $url
*/
public function __construct(NotificationRepository $notifications, UrlGenerator $url)
{
$this->notifications = $notifications;
$this->url = $url;
}
/**
@ -68,10 +76,33 @@ class ListNotificationsController extends AbstractListController
$offset = $this->extractOffset($request);
$include = $this->extractInclude($request);
$notifications = $this->notifications->findByUser($actor, $limit, $offset)
if (! in_array('subject', $include)) {
$include[] = 'subject';
}
$notifications = $this->notifications->findByUser($actor, $limit + 1, $offset)
->load(array_diff($include, ['subject.discussion']))
->all();
$areMoreResults = false;
if (count($notifications) > $limit) {
array_pop($notifications);
$areMoreResults = true;
}
$document->addPaginationLinks(
$this->url->toRoute('notifications.index'),
$request->getQueryParams(),
$offset,
$limit,
$areMoreResults ? null : 0
);
$notifications = array_filter($notifications, function ($notification) {
return ! $notification->subjectModel || $notification->subject;
});
if (in_array('subject.discussion', $include)) {
$this->loadSubjectDiscussions($notifications);
}

View File

@ -173,6 +173,12 @@ class ShowDiscussionController extends AbstractShowController
$query->orderBy('time')->skip($offset)->take($limit)->with($include);
return $query->get()->all();
$posts = $query->get()->all();
foreach ($posts as $post) {
$post->discussion = $discussion;
}
return $posts;
}
}

View File

@ -80,7 +80,7 @@ class UploadFaviconController extends ShowForumController
$mount->delete($file);
}
$uploadName = 'favicon-'.Str::lower(Str::quickRandom(8)).'.'.$extension;
$uploadName = 'favicon-'.Str::lower(Str::random(8)).'.'.$extension;
$mount->move('source://'.pathinfo($tmpFile, PATHINFO_BASENAME), "target://$uploadName");

View File

@ -73,7 +73,7 @@ class UploadLogoController extends ShowForumController
$mount->delete($file);
}
$uploadName = 'logo-'.Str::lower(Str::quickRandom(8)).'.png';
$uploadName = 'logo-'.Str::lower(Str::random(8)).'.png';
$mount->move('source://'.pathinfo($tmpFile, PATHINFO_BASENAME), "target://$uploadName");

View File

@ -88,4 +88,12 @@ class BasicDiscussionSerializer extends AbstractSerializer
{
return $this->hasMany($discussion, 'Flarum\Api\Serializer\BasicPostSerializer');
}
/**
* @return \Tobscure\JsonApi\Relationship
*/
protected function hideUser($discussion)
{
return $this->hasOne($discussion, 'Flarum\Api\Serializer\UserBasicSerializer');
}
}

View File

@ -36,8 +36,9 @@ class BasicUserSerializer extends AbstractSerializer
}
return [
'username' => $user->username,
'avatarUrl' => $user->avatar_url
'username' => $user->username,
'displayName' => $user->display_name,
'avatarUrl' => $user->avatar_url
];
}

View File

@ -64,12 +64,4 @@ class DiscussionSerializer extends BasicDiscussionSerializer
return $attributes;
}
/**
* @return \Tobscure\JsonApi\Relationship
*/
protected function hideUser($discussion)
{
return $this->hasOne($discussion, 'Flarum\Api\Serializer\UserSerializer');
}
}

View File

@ -85,7 +85,7 @@ class PostSerializer extends BasicPostSerializer
*/
protected function discussion($post)
{
return $this->hasOne($post, 'Flarum\Api\Serializer\DiscussionSerializer');
return $this->hasOne($post, 'Flarum\Api\Serializer\DiscussionBasicSerializer');
}
/**
@ -93,7 +93,7 @@ class PostSerializer extends BasicPostSerializer
*/
protected function editUser($post)
{
return $this->hasOne($post, 'Flarum\Api\Serializer\UserSerializer');
return $this->hasOne($post, 'Flarum\Api\Serializer\UserBasicSerializer');
}
/**
@ -101,6 +101,6 @@ class PostSerializer extends BasicPostSerializer
*/
protected function hideUser($post)
{
return $this->hasOne($post, 'Flarum\Api\Serializer\UserSerializer');
return $this->hasOne($post, 'Flarum\Api\Serializer\UserBasicSerializer');
}
}

View File

@ -40,7 +40,6 @@ class UserSerializer extends BasicUserSerializer
$canEdit = $gate->allows('edit', $user);
$attributes += [
'bio' => $user->bio,
'joinTime' => $this->formatDate($user->join_time),
'discussionsCount' => (int) $user->discussions_count,
'commentsCount' => (int) $user->comments_count,

View File

@ -0,0 +1,60 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Core;
use Flarum\User\User;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use League\Flysystem\FilesystemInterface;
class AvatarUploader
{
protected $uploadDir;
public function __construct(FilesystemInterface $uploadDir)
{
$this->uploadDir = $uploadDir;
}
/**
* @param User $user
* @param Image $image
*/
public function upload(User $user, Image $image)
{
if (extension_loaded('exif')) {
$image->orientate();
}
$encodedImage = $image->fit(100, 100)->encode('png');
$avatarPath = Str::random().'.png';
$this->remove($user);
$user->changeAvatarPath($avatarPath);
$this->uploadDir->put($avatarPath, $encodedImage);
}
public function remove(User $user)
{
$avatarPath = $user->avatar_path;
$user->afterSave(function () use ($avatarPath) {
if ($this->uploadDir->has($avatarPath)) {
$this->uploadDir->delete($avatarPath);
}
});
$user->changeAvatarPath(null);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Core\Listener;
use Flarum\Core\Exception\ValidationException;
use Flarum\Event\PrepareSerializedSetting;
use Illuminate\Contracts\Events\Dispatcher;
use Less_Exception_Parser;
use Less_Parser;
class CheckCustomLessFormat
{
public function subscribe(Dispatcher $events)
{
$events->listen(PrepareSerializedSetting::class, [$this, 'check']);
}
public function check(PrepareSerializedSetting $event)
{
if ($event->key === 'custom_less') {
$parser = new Less_Parser();
try {
// Check the custom less format before saving
// Variables names are not checked, we would have to set them and call getCss() to check them
$parser->parse($event->value);
} catch (Less_Exception_Parser $e) {
throw new ValidationException([
'custom_less' => $e->getMessage(),
]);
}
}
}
}

View File

@ -107,7 +107,7 @@ class MigrationCreator
*/
protected function getMigrationPath($extension)
{
$parent = $extension ? public_path().'/extensions/'.$extension : __DIR__.'/../..';
$parent = $extension ? public_path('extensions/'.$extension) : __DIR__.'/../..';
return $parent.'/migrations';
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\User\User;
class GetDisplayName
{
/**
* @var User
*/
public $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@ -66,7 +66,7 @@ abstract class AbstractOAuth2Controller implements ControllerInterface
return new RedirectResponse($authUrl.'&display=popup');
} elseif (! $state || $state !== $session->get('oauth2state')) {
$session->forget('oauth2state');
$session->remove('oauth2state');
echo 'Invalid state. Please close the window and try again.';
exit;
}

View File

@ -87,7 +87,7 @@ class DiscussionController extends FrontendController
$view->title = $document->data->attributes->title;
$view->document = $document;
$view->content = app('view')->make('flarum.forum::discussion', compact('document', 'page', 'getResource', 'posts', 'url'));
$view->content = app('view')->make('flarum.forum::content.discussion', compact('document', 'page', 'getResource', 'posts', 'url'));
return $view;
}

View File

@ -68,7 +68,7 @@ class IndexController extends FrontendController
$document = $this->getDocument($request->getAttribute('actor'), $params);
$view->document = $document;
$view->content = app('view')->make('flarum.forum::index', compact('document', 'page', 'forum'));
$view->content = app('view')->make('flarum.forum::content.index', compact('document', 'page', 'forum'));
return $view;
}

View File

@ -81,7 +81,9 @@ class LogInController implements ControllerInterface
event(new LoggedIn($this->users->findOrFail($data->userId), $token));
$response = $this->rememberer->remember($response, $token, ! array_get($body, 'remember'));
if (array_get($body, 'remember')) {
$response = $this->rememberer->remember($response, $token);
}
}
return $response;

View File

@ -16,10 +16,13 @@ use Flarum\Http\Controller\ControllerInterface;
use Flarum\Http\Exception\TokenMismatchException;
use Flarum\Http\Rememberer;
use Flarum\Http\SessionAuthenticator;
use Flarum\Http\UrlGenerator;
use Flarum\User\AssertPermissionTrait;
use Flarum\User\Event\LoggedOut;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\View\Factory;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Response\HtmlResponse;
use Zend\Diactoros\Response\RedirectResponse;
class LogOutController implements ControllerInterface
@ -46,18 +49,38 @@ class LogOutController implements ControllerInterface
*/
protected $rememberer;
/**
* @var Factory
*/
protected $view;
/**
* @var UrlGenerator
*/
protected $url;
/**
* @param Application $app
* @param Dispatcher $events
* @param SessionAuthenticator $authenticator
* @param Rememberer $rememberer
* @param Factory $view
* @param UrlGenerator $url
*/
public function __construct(Application $app, Dispatcher $events, SessionAuthenticator $authenticator, Rememberer $rememberer)
{
public function __construct(
Application $app,
Dispatcher $events,
SessionAuthenticator $authenticator,
Rememberer $rememberer,
Factory $view,
UrlGenerator $url
) {
$this->app = $app;
$this->events = $events;
$this->authenticator = $authenticator;
$this->rememberer = $rememberer;
$this->view = $view;
$this->url = $url;
}
/**
@ -68,17 +91,28 @@ class LogOutController implements ControllerInterface
public function handle(Request $request)
{
$session = $request->getAttribute('session');
if (array_get($request->getQueryParams(), 'token') !== $session->get('csrf_token')) {
throw new TokenMismatchException;
}
$actor = $request->getAttribute('actor');
$this->assertRegistered($actor);
$url = array_get($request->getQueryParams(), 'return', $this->app->url());
// If there is no user logged in, return to the index.
if ($actor->isGuest()) {
return new RedirectResponse($url);
}
// If a valid CSRF token hasn't been provided, show a view which will
// allow the user to press a button to complete the log out process.
$csrfToken = $session->get('csrf_token');
if (array_get($request->getQueryParams(), 'token') !== $csrfToken) {
$return = array_get($request->getQueryParams(), 'return');
$view = $this->view->make('flarum.forum::log-out')
->with('url', $this->url->toRoute('logout').'?token='.$csrfToken.($return ? '&return='.urlencode($return) : ''));
return new HtmlResponse($view->render());
}
$response = new RedirectResponse($url);
$this->authenticator->logOut($session);

View File

@ -17,7 +17,6 @@ use Flarum\User\Exception\InvalidConfirmationTokenException;
use Flarum\User\PasswordToken;
use Illuminate\Contracts\View\Factory;
use Psr\Http\Message\ServerRequestInterface as Request;
use Symfony\Component\Translation\TranslatorInterface;
class ResetPasswordController extends AbstractHtmlController
{
@ -26,19 +25,13 @@ class ResetPasswordController extends AbstractHtmlController
*/
protected $view;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param Factory $view
* @param TranslatorInterface $translator
*/
public function __construct(Factory $view, TranslatorInterface $translator)
public function __construct(Factory $view)
{
$this->view = $view;
$this->translator = $translator;
}
/**
@ -56,10 +49,8 @@ class ResetPasswordController extends AbstractHtmlController
throw new InvalidConfirmationTokenException;
}
return $this->view->make('flarum::reset')
->with('translator', $this->translator)
return $this->view->make('flarum.forum::reset-password')
->with('passwordToken', $token->id)
->with('csrfToken', $request->getAttribute('session')->get('csrf_token'))
->with('error', $request->getAttribute('session')->get('error'));
->with('csrfToken', $request->getAttribute('session')->get('csrf_token'));
}
}

View File

@ -75,11 +75,12 @@ class SavePasswordController implements ControllerInterface
$this->validator->assertValid(compact('password'));
$validator = $this->validatorFactory->make($input, ['password' => 'required|confirmed']);
if ($validator->fails()) {
throw new ValidationException($validator);
}
} catch (ValidationException $e) {
$request->getAttribute('session')->set('error', $e->errors()->first());
$request->getAttribute('session')->set('errors', $e->errors());
return new RedirectResponse($this->url->to('forum')->route('resetPassword', ['token' => $token->id]));
}

View File

@ -26,8 +26,9 @@ use Flarum\Http\Middleware\StartSession;
use Flarum\Http\RouteCollection;
use Flarum\Http\RouteHandlerFactory;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\Event\Saved;
use Zend\Stratigility\MiddlewarePipe;
use Flarum\Settings\SettingsRepositoryInterface;
use Symfony\Component\Translation\TranslatorInterface;
class ForumServiceProvider extends AbstractServiceProvider
{
@ -50,8 +51,7 @@ class ForumServiceProvider extends AbstractServiceProvider
// All requests should first be piped through our global error handler
$debugMode = ! $app->isUpToDate() || $app->inDebugMode();
$errorDir = __DIR__.'/../../error';
$pipe->pipe(new HandleErrors($errorDir, $app->make('log'), $debugMode));
$pipe->pipe($app->make(HandleErrors::class, ['debug' => $debugMode]));
$pipe->pipe($app->make(ParseJsonBody::class));
$pipe->pipe($app->make(StartSession::class));
@ -74,7 +74,12 @@ class ForumServiceProvider extends AbstractServiceProvider
{
$this->populateRoutes($this->app->make('flarum.forum.routes'));
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum.forum');
$this->loadViewsFrom(__DIR__.'/../../views/frontend', 'flarum.forum');
$this->app->make('view')->share([
'translator' => $this->app->make(TranslatorInterface::class),
'settings' => $this->app->make(SettingsRepositoryInterface::class)
]);
$this->flushWebAppAssetsWhenThemeChanged();

View File

@ -51,6 +51,10 @@ class Frontend extends AbstractFrontend
return $this->formatter->getJs();
});
$view->getCss()->addString(function () {
return $this->settings->get('custom_less');
});
return $view;
}

View File

@ -144,7 +144,7 @@ class Application extends Container implements ApplicationContract
*/
public function config($key, $default = null)
{
return array_get($this->make('flarum.config'), $key, $default);
return $this->isInstalled() ? array_get($this->make('flarum.config'), $key, $default) : $default;
}
/**

View File

@ -14,35 +14,51 @@ namespace Flarum\Foundation\Console;
use Flarum\Admin\Frontend as AdminWebApp;
use Flarum\Console\AbstractCommand;
use Flarum\Forum\Frontend as ForumWebApp;
use Flarum\Foundation\Application;
use Illuminate\Contracts\Cache\Store;
class CacheClearCommand extends AbstractCommand
{
/**
* @var \Illuminate\Contracts\Cache\Store
* @var Store
*/
protected $cache;
/**
<<<<<<< HEAD:src/Foundation/Console/CacheClearCommand.php
* @var \Flarum\Forum\Frontend
=======
* @var ForumWebApp
>>>>>>> master:src/Debug/Console/CacheClearCommand.php
*/
protected $forum;
/**
<<<<<<< HEAD:src/Foundation/Console/CacheClearCommand.php
* @var \Flarum\Admin\Frontend
=======
* @var AdminWebApp
>>>>>>> master:src/Debug/Console/CacheClearCommand.php
*/
protected $admin;
/**
* @var Application
*/
protected $app;
/**
* @param Store $cache
* @param ForumWebApp $forum
* @param AdminWebApp $admin
* @param Application $app
*/
public function __construct(Store $cache, ForumWebApp $forum, AdminWebApp $admin)
public function __construct(Store $cache, ForumWebApp $forum, AdminWebApp $admin, Application $app)
{
$this->cache = $cache;
$this->forum = $forum;
$this->admin = $admin;
$this->app = $app;
parent::__construct();
}
@ -68,5 +84,9 @@ class CacheClearCommand extends AbstractCommand
$this->admin->getAssets()->flush();
$this->cache->flush();
$storagePath = $this->app->storagePath();
array_map('unlink', glob($storagePath.'/formatter/*'));
array_map('unlink', glob($storagePath.'/locale/*'));
}
}

View File

@ -86,7 +86,7 @@ abstract class AbstractFrontend
*/
protected function getLayout()
{
return __DIR__.'/../../views/'.$this->getName().'.blade.php';
return 'flarum.forum::'.$this->getName();
}
/**
@ -131,10 +131,6 @@ abstract class AbstractFrontend
$css->addString($lessVariables);
$localeCss->addString($lessVariables);
$css->addString(function () {
return $this->settings->get('custom_less');
});
}
/**

View File

@ -49,7 +49,7 @@ class LessCompiler extends RevisionCompiler
'compress' => true,
'cache_dir' => $this->cachePath,
'import_dirs' => [
base_path().'/vendor/components/font-awesome/less' => '',
base_path('vendor/components/font-awesome/less') => '',
],
]);

View File

@ -81,6 +81,10 @@ class RevisionCompiler implements CompilerInterface
$cacheDifferentiator[] = [$source, filemtime($source)];
}
foreach ($this->strings as $callback) {
$cacheDifferentiator[] = $callback();
}
$current = hash('crc32b', serialize($cacheDifferentiator));
}

View File

@ -287,7 +287,7 @@ class FrontendView
$this->view->share('forum', array_get($forum, 'data'));
$this->view->share('debug', $this->app->inDebugMode());
$view = $this->view->file(__DIR__.'/../../views/app.blade.php');
$view = $this->view->make('flarum.forum::app');
$view->title = $this->buildTitle(array_get($forum, 'data.attributes.title'));
$view->description = $this->description ?: array_get($forum, 'data.attributes.description');
@ -336,7 +336,7 @@ class FrontendView
protected function buildLayout()
{
$view = $this->view->file($this->layout);
$view = $this->view->make($this->layout);
$view->content = $this->buildContent();
@ -345,7 +345,7 @@ class FrontendView
protected function buildContent()
{
$view = $this->view->file(__DIR__.'/../../views/content.blade.php');
$view = $this->view->make('flarum.forum::content');
$view->content = $this->content;

View File

@ -11,28 +11,30 @@
namespace Flarum\Http\Controller;
use Illuminate\Contracts\Support\Renderable;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\HtmlResponse;
abstract class AbstractHtmlController implements ControllerInterface
{
/**
* @param Request $request
* @return \Zend\Diactoros\Response
* @return HtmlResponse
*/
public function handle(Request $request)
{
$view = $this->render($request);
$response = new Response;
$response->getBody()->write($view);
if ($view instanceof Renderable) {
$view = $view->render();
}
return $response;
return new HtmlResponse($view);
}
/**
* @param Request $request
* @return \Illuminate\Contracts\Support\Renderable
* @return string|Renderable
*/
abstract protected function render(Request $request);
}

View File

@ -17,16 +17,46 @@ use Flarum\Foundation\Application;
class CookieFactory
{
/**
* @var Application
* The prefix for the cookie names.
*
* @var string
*/
protected $app;
protected $prefix;
/**
* A path scope for the cookies.
*
* @var string
*/
protected $path;
/**
* A domain scope for the cookies.
*
* @var string
*/
protected $domain;
/**
* Whether the cookie(s) can be requested only over HTTPS.
*
* @var bool
*/
protected $secure;
/**
* @param Application $app
*/
public function __construct(Application $app)
{
$this->app = $app;
// Parse the forum's base URL so that we can determine the optimal cookie settings
$url = parse_url(rtrim($app->url(), '/'));
// Get the cookie settings from the config or use the default values
$this->prefix = $app->config('cookie.name', 'flarum');
$this->path = $app->config('cookie.path', array_get($url, 'path') ?: '/');
$this->domain = $app->config('cookie.domain');
$this->secure = $app->config('cookie.secure', array_get($url, 'scheme') === 'https');
}
/**
@ -42,10 +72,7 @@ class CookieFactory
*/
public function make($name, $value = null, $maxAge = null)
{
// Parse the forum's base URL so that we can determine the optimal cookie settings
$url = parse_url(rtrim($this->app->url(), '/'));
$cookie = SetCookie::create($name, $value);
$cookie = SetCookie::create($this->getName($name), $value);
// Make sure we send both the MaxAge and Expires parameters (the former
// is not supported by all browser versions)
@ -55,9 +82,35 @@ class CookieFactory
->withExpires(time() + $maxAge);
}
if ($this->domain != null) {
$cookie = $cookie->withDomain($this->domain);
}
return $cookie
->withPath(array_get($url, 'path') ?: '/')
->withSecure(array_get($url, 'scheme') === 'https')
->withPath($this->path)
->withSecure($this->secure)
->withHttpOnly(true);
}
/**
* Make an expired cookie instance.
*
* @param string $name
* @return \Dflydev\FigCookies\SetCookie
*/
public function expire($name)
{
return $this->make($name)->expire();
}
/**
* Get a cookie name.
*
* @param string $name
* @return string
*/
public function getName($name)
{
return $this->prefix.'_'.$name;
}
}

View File

@ -20,10 +20,7 @@ use Zend\Stratigility\MiddlewareInterface;
class AuthenticateWithHeader implements MiddlewareInterface
{
/**
* @var string
*/
protected $prefix = 'Token ';
const TOKEN_PREFIX = 'Token ';
/**
* {@inheritdoc}
@ -34,13 +31,14 @@ class AuthenticateWithHeader implements MiddlewareInterface
$parts = explode(';', $headerLine);
if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) {
$id = substr($parts[0], strlen($this->prefix));
if (isset($parts[0]) && starts_with($parts[0], self::TOKEN_PREFIX)) {
$id = substr($parts[0], strlen(self::TOKEN_PREFIX));
if (isset($parts[1])) {
if (ApiKey::find($id)) {
if ($key = ApiKey::find($id)) {
$actor = $this->getUser($parts[1]);
$request = $request->withAttribute('apiKey', $key);
$request = $request->withAttribute('bypassFloodgate', true);
}
} elseif ($token = AccessToken::find($id)) {

View File

@ -12,38 +12,55 @@
namespace Flarum\Http\Middleware;
use Exception;
use Flarum\Settings\SettingsRepositoryInterface;
use Franzl\Middleware\Whoops\ErrorMiddleware as WhoopsMiddleware;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Symfony\Component\Translation\TranslatorInterface;
use Zend\Diactoros\Response\HtmlResponse;
class HandleErrors
{
/**
* @var string
* @var ViewFactory
*/
protected $templateDir;
protected $view;
/**
* @var LoggerInterface
*/
protected $logger;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var bool
*/
protected $debug;
/**
* @param string $templateDir
* @param ViewFactory $view
* @param LoggerInterface $logger
* @param TranslatorInterface $translator
* @param SettingsRepositoryInterface $settings
* @param bool $debug
*/
public function __construct($templateDir, LoggerInterface $logger, $debug = false)
public function __construct(ViewFactory $view, LoggerInterface $logger, TranslatorInterface $translator, SettingsRepositoryInterface $settings, $debug = false)
{
$this->templateDir = $templateDir;
$this->view = $view;
$this->logger = $logger;
$this->translator = $translator;
$this->settings = $settings;
$this->debug = $debug;
}
@ -75,7 +92,7 @@ class HandleErrors
$status = $errorCode;
}
if ($this->debug && ! in_array($errorCode, [403, 404])) {
if ($this->debug) {
$whoops = new WhoopsMiddleware;
return $whoops($error, $request, $response, $out);
@ -84,21 +101,33 @@ class HandleErrors
// Log the exception (with trace)
$this->logger->debug($error);
$errorPage = $this->getErrorPage($status);
return new HtmlResponse($errorPage, $status);
}
/**
* @param string $status
* @return string
*/
protected function getErrorPage($status)
{
if (! file_exists($errorPage = $this->templateDir."/$status.html")) {
$errorPage = $this->templateDir.'/500.html';
if (! $this->view->exists($name = 'flarum.forum::error.'.$status)) {
$name = 'flarum.forum::error.default';
}
return file_get_contents($errorPage);
$view = $this->view->make($name)
->with('error', $error)
->with('message', $this->getMessage($status));
return new HtmlResponse($view->render(), $status);
}
private function getMessage($status)
{
if (! $translation = $this->getTranslationIfExists($status)) {
if (! $translation = $this->getTranslationIfExists(500)) {
$translation = 'An error occurred while trying to load this page.';
}
}
return $translation;
}
private function getTranslationIfExists($status)
{
$key = 'core.views.error.'.$status.'_message';
$translation = $this->translator->trans($key, ['{forum}' => $this->settings->get('forum_title')]);
return $translation === $key ? false : $translation;
}
}

View File

@ -0,0 +1,62 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Http\Middleware;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Support\ViewErrorBag;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
/**
* Inspired by Illuminate\View\Middleware\ShareErrorsFromSession.
*
* @author Taylor Otwell
*/
class ShareErrorsFromSession implements MiddlewareInterface
{
/**
* @var ViewFactory
*/
protected $view;
/**
* @param ViewFactory $view
*/
public function __construct(ViewFactory $view)
{
$this->view = $view;
}
/**
* {@inheritdoc}
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$session = $request->getAttribute('session');
// If the current session has an "errors" variable bound to it, we will share
// its value with all view instances so the views can easily access errors
// without having to bind. An empty bag is set when there aren't errors.
$this->view->share(
'errors', $session->get('errors', new ViewErrorBag)
);
// Putting the errors in the view for every view allows the developer to just
// assume that some errors are always available, which is convenient since
// they don't have to continually run checks for the presence of errors.
$session->remove('errors');
return $out ? $out($request, $response) : $response;
}
}

View File

@ -22,13 +22,14 @@ use Zend\Stratigility\MiddlewareInterface;
class StartSession implements MiddlewareInterface
{
const COOKIE_NAME = 'session';
/**
* @var CookieFactory
*/
protected $cookie;
/**
* Rememberer constructor.
* @param CookieFactory $cookie
*/
public function __construct(CookieFactory $cookie)
@ -56,7 +57,7 @@ class StartSession implements MiddlewareInterface
{
$session = new Session;
$session->setName('flarum_session');
$session->setName($this->cookie->getName(self::COOKIE_NAME));
$session->start();
if (! $session->has('csrf_token')) {
@ -79,7 +80,7 @@ class StartSession implements MiddlewareInterface
{
return FigResponseCookies::set(
$response,
$this->cookie->make($session->getName(), $session->getId())
$this->cookie->make(self::COOKIE_NAME, $session->getId())
);
}
}

View File

@ -16,7 +16,7 @@ use Psr\Http\Message\ResponseInterface;
class Rememberer
{
protected $cookieName = 'flarum_remember';
const COOKIE_NAME = 'remember';
/**
* @var CookieFactory
@ -24,7 +24,6 @@ class Rememberer
protected $cookie;
/**
* Rememberer constructor.
* @param CookieFactory $cookie
*/
public function __construct(CookieFactory $cookie)
@ -32,18 +31,16 @@ class Rememberer
$this->cookie = $cookie;
}
public function remember(ResponseInterface $response, AccessToken $token, $session = false)
public function remember(ResponseInterface $response, AccessToken $token)
{
$lifetime = null;
if (! $session) {
$token->lifetime = $lifetime = 5 * 365 * 24 * 60 * 60; // 5 years
$token->save();
}
$token->lifetime = 5 * 365 * 24 * 60 * 60; // 5 years
$token->save();
return FigResponseCookies::set(
$response,
$this->cookie->make($this->cookieName, $token->id, $lifetime)
$this->cookie->make(self::COOKIE_NAME, $token->id, $token->lifetime)
);
}
@ -56,6 +53,9 @@ class Rememberer
public function forget(ResponseInterface $response)
{
return FigResponseCookies::expire($response, $this->cookieName);
return FigResponseCookies::set(
$response,
$this->cookie->expire(self::COOKIE_NAME)
);
}
}

View File

@ -152,6 +152,16 @@ class CommentPost extends Post
$this->attributes['content'] = $value ? static::$formatter->parse($value, $this) : null;
}
/**
* Set the parsed/raw content.
*
* @param string $value
*/
public function setParsedContentAttribute($value)
{
$this->attributes['content'] = $value;
}
/**
* Get the content rendered as HTML.
*

View File

@ -152,7 +152,7 @@ class PostRepository
{
$discussions = $this->getDiscussionsForPosts($ids, $actor);
return Post::whereIn('id', $ids)
$posts = Post::whereIn('id', $ids)
->where(function ($query) use ($discussions, $actor) {
foreach ($discussions as $discussion) {
$query->orWhere(function ($query) use ($discussion, $actor) {
@ -164,6 +164,12 @@ class PostRepository
$query->orWhereRaw('FALSE');
});
foreach ($posts as $post) {
$post->discussion = $discussions->find($post->discussion_id);
}
return $posts;
}
/**

View File

@ -15,8 +15,9 @@ use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\AssertPermissionTrait;
use Flarum\User\Event\AvatarDeleting;
use Flarum\User\UserRepository;
use Flarum\Core\AvatarUploader;
use Flarum\Event\AvatarWillBeDeleted;
use Illuminate\Contracts\Events\Dispatcher;
use League\Flysystem\FilesystemInterface;
class DeleteAvatarHandler
{
@ -29,20 +30,20 @@ class DeleteAvatarHandler
protected $users;
/**
* @var FilesystemInterface
* @var AvatarUploader
*/
protected $uploadDir;
protected $uploader;
/**
* @param Dispatcher $events
* @param UserRepository $users
* @param FilesystemInterface $uploadDir
* @param AvatarUploader $uploader
*/
public function __construct(Dispatcher $events, UserRepository $users, FilesystemInterface $uploadDir)
public function __construct(Dispatcher $events, UserRepository $users, AvatarUploader $uploader)
{
$this->events = $events;
$this->users = $users;
$this->uploadDir = $uploadDir;
$this->uploader = $uploader;
}
/**
@ -60,8 +61,7 @@ class DeleteAvatarHandler
$this->assertCan($actor, 'edit', $user);
}
$avatarPath = $user->avatar_path;
$user->changeAvatarPath(null);
$this->uploader->remove($user);
$this->events->fire(
new AvatarDeleting($user, $actor)
@ -69,10 +69,6 @@ class DeleteAvatarHandler
$user->save();
if ($this->uploadDir->has($avatarPath)) {
$this->uploadDir->delete($avatarPath);
}
$this->dispatchEventsFor($user, $actor);
return $user;

View File

@ -11,6 +11,8 @@
namespace Flarum\User\Command;
use Exception;
use Flarum\Core\AvatarUploader;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\AssertPermissionTrait;
use Flarum\User\Event\GroupsChanged;
@ -19,6 +21,9 @@ use Flarum\User\User;
use Flarum\User\UserRepository;
use Flarum\User\UserValidator;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Validation\ValidationException;
use Intervention\Image\ImageManager;
class EditUserHandler
{
@ -35,16 +40,30 @@ class EditUserHandler
*/
protected $validator;
/**
* @var AvatarUploader
*/
protected $avatarUploader;
/**
* @var Factory
*/
private $validatorFactory;
/**
* @param Dispatcher $events
* @param \Flarum\User\UserRepository $users
* @param UserValidator $validator
* @param AvatarUploader $avatarUploader
* @param Factory $validatorFactory
*/
public function __construct(Dispatcher $events, UserRepository $users, UserValidator $validator)
public function __construct(Dispatcher $events, UserRepository $users, UserValidator $validator, AvatarUploader $avatarUploader, Factory $validatorFactory)
{
$this->events = $events;
$this->users = $users;
$this->validator = $validator;
$this->avatarUploader = $avatarUploader;
$this->validatorFactory = $validatorFactory;
}
/**
@ -95,14 +114,6 @@ class EditUserHandler
$validate['password'] = $attributes['password'];
}
if (isset($attributes['bio'])) {
if (! $isSelf) {
$this->assertPermission($canEdit);
}
$user->changeBio($attributes['bio']);
}
if (! empty($attributes['readTime'])) {
$this->assertPermission($isSelf);
$user->markAllAsRead();
@ -135,6 +146,24 @@ class EditUserHandler
});
}
if ($avatarUrl = array_get($attributes, 'avatarUrl')) {
$validation = $this->validatorFactory->make(compact('avatarUrl'), ['avatarUrl' => 'url']);
if ($validation->fails()) {
throw new ValidationException($validation);
}
try {
$image = (new ImageManager)->make($avatarUrl);
$this->avatarUploader->upload($user, $image);
} catch (Exception $e) {
//
}
} elseif (array_key_exists('avatarUrl', $attributes)) {
$this->avatarUploader->remove($user);
}
$this->events->fire(
new Saving($user, $actor, $data)
);

View File

@ -12,7 +12,7 @@
namespace Flarum\User\Command;
use Exception;
use Flarum\Foundation\Application;
use Flarum\Core\AvatarUploader;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
@ -23,13 +23,8 @@ use Flarum\User\User;
use Flarum\User\UserValidator;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Intervention\Image\ImageManager;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemInterface;
use League\Flysystem\MountManager;
class RegisterUserHandler
{
@ -47,14 +42,9 @@ class RegisterUserHandler
protected $validator;
/**
* @var Application
* @var AvatarUploader
*/
protected $app;
/**
* @var FilesystemInterface
*/
protected $uploadDir;
protected $avatarUploader;
/**
* @var Factory
@ -65,17 +55,15 @@ class RegisterUserHandler
* @param Dispatcher $events
* @param SettingsRepositoryInterface $settings
* @param UserValidator $validator
* @param Application $app
* @param FilesystemInterface $uploadDir
* @param AvatarUploader $avatarUploader
* @param Factory $validatorFactory
*/
public function __construct(Dispatcher $events, SettingsRepositoryInterface $settings, UserValidator $validator, Application $app, FilesystemInterface $uploadDir, Factory $validatorFactory)
public function __construct(Dispatcher $events, SettingsRepositoryInterface $settings, UserValidator $validator, AvatarUploader $avatarUploader, Factory $validatorFactory)
{
$this->events = $events;
$this->settings = $settings;
$this->validator = $validator;
$this->app = $app;
$this->uploadDir = $uploadDir;
$this->avatarUploader = $avatarUploader;
$this->validatorFactory = $validatorFactory;
}
@ -144,7 +132,9 @@ class RegisterUserHandler
}
try {
$this->saveAvatarFromUrl($user, $avatarUrl);
$image = (new ImageManager)->make($avatarUrl);
$this->avatarUploader->upload($user, $image);
} catch (Exception $e) {
//
}
@ -160,23 +150,4 @@ class RegisterUserHandler
return $user;
}
private function saveAvatarFromUrl(User $user, $url)
{
$tmpFile = tempnam($this->app->storagePath().'/tmp', 'avatar');
$manager = new ImageManager;
$manager->make($url)->fit(100, 100)->save($tmpFile);
$mount = new MountManager([
'source' => new Filesystem(new Local(pathinfo($tmpFile, PATHINFO_DIRNAME))),
'target' => $this->uploadDir,
]);
$uploadName = Str::lower(Str::quickRandom()).'.png';
$user->changeAvatarPath($uploadName);
$mount->move('source://'.pathinfo($tmpFile, PATHINFO_BASENAME), "target://$uploadName");
}
}

View File

@ -18,9 +18,9 @@ use Flarum\User\UserRepository;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Translation\Translator;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Mail\Message;
use Illuminate\Validation\ValidationException;
class RequestPasswordResetHandler
{
@ -55,7 +55,7 @@ class RequestPasswordResetHandler
protected $validatorFactory;
/**
* @param \Flarum\User\UserRepository $users
* @param UserRepository $users
* @param SettingsRepositoryInterface $settings
* @param Mailer $mailer
* @param UrlGenerator $url
@ -106,7 +106,7 @@ class RequestPasswordResetHandler
$token->save();
$data = [
'{username}' => $user->username,
'{username}' => $user->display_name,
'{url}' => $this->url->to('forum')->route('resetPassword', ['token' => $token->id]),
'{forum}' => $this->settings->get('forum_title'),
];

View File

@ -12,6 +12,7 @@
namespace Flarum\User\Command;
use Exception;
use Flarum\Core\AvatarUploader;
use Flarum\Foundation\Application;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\AssertPermissionTrait;
@ -19,12 +20,7 @@ use Flarum\User\AvatarValidator;
use Flarum\User\Event\AvatarSaving;
use Flarum\User\UserRepository;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemInterface;
use League\Flysystem\MountManager;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class UploadAvatarHandler
@ -37,16 +33,16 @@ class UploadAvatarHandler
*/
protected $users;
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @var Application
*/
protected $app;
/**
* @var AvatarUploader
*/
protected $uploader;
/**
* @var \Flarum\User\AvatarValidator
*/
@ -55,16 +51,16 @@ class UploadAvatarHandler
/**
* @param Dispatcher $events
* @param UserRepository $users
* @param FilesystemInterface $uploadDir
* @param Application $app
* @param \Flarum\User\AvatarValidator $validator
* @param AvatarUploader $uploader
* @param AvatarValidator $validator
*/
public function __construct(Dispatcher $events, UserRepository $users, FilesystemInterface $uploadDir, Application $app, AvatarValidator $validator)
public function __construct(Dispatcher $events, UserRepository $users, Application $app, AvatarUploader $uploader, AvatarValidator $validator)
{
$this->events = $events;
$this->users = $users;
$this->uploadDir = $uploadDir;
$this->app = $app;
$this->uploader = $uploader;
$this->validator = $validator;
}
@ -83,60 +79,36 @@ class UploadAvatarHandler
$this->assertCan($actor, 'edit', $user);
}
$file = $command->file;
$tmpFile = tempnam($this->app->storagePath().'/tmp', 'avatar');
$command->file->moveTo($tmpFile);
$file->moveTo($tmpFile);
try {
$file = new UploadedFile(
$tmpFile,
$command->file->getClientFilename(),
$command->file->getClientMediaType(),
$command->file->getSize(),
$command->file->getError(),
$file->getClientFilename(),
$file->getClientMediaType(),
$file->getSize(),
$file->getError(),
true
);
$this->validator->assertValid(['avatar' => $file]);
$manager = new ImageManager;
// Explicitly tell Intervention to encode the image as PNG (instead of having to guess from the extension)
// Read exif data to orientate avatar only if EXIF extension is enabled
if (extension_loaded('exif')) {
$encodedImage = $manager->make($tmpFile)->orientate()->fit(100, 100)->encode('png', 100);
} else {
$encodedImage = $manager->make($tmpFile)->fit(100, 100)->encode('png', 100);
}
file_put_contents($tmpFile, $encodedImage);
$image = (new ImageManager)->make($tmpFile);
$this->events->fire(
new AvatarSaving($user, $actor, $tmpFile)
);
$mount = new MountManager([
'source' => new Filesystem(new Local(pathinfo($tmpFile, PATHINFO_DIRNAME))),
'target' => $this->uploadDir,
]);
if ($user->avatar_path && $mount->has($file = "target://$user->avatar_path")) {
$mount->delete($file);
}
$uploadName = Str::lower(Str::quickRandom()).'.png';
$user->changeAvatarPath($uploadName);
$mount->move('source://'.pathinfo($tmpFile, PATHINFO_BASENAME), "target://$uploadName");
$this->uploader->upload($user, $image);
$user->save();
$this->dispatchEventsFor($user, $actor);
return $user;
} catch (Exception $e) {
} finally {
@unlink($tmpFile);
throw $e;
}
return $user;
}
}

View File

@ -127,7 +127,7 @@ class EmailConfirmationMailer
$token = $this->generateToken($user, $email);
return [
'{username}' => $user->username,
'{username}' => $user->display_name,
'{url}' => $this->url->to('forum')->route('confirmEmail', ['token' => $token->id]),
'{forum}' => $this->settings->get('forum_title')
];

View File

@ -12,6 +12,7 @@
namespace Flarum\User\Event;
use Flarum\User\User;
use Intervention\Image\Image;
class AvatarSaving
{
@ -30,21 +31,21 @@ class AvatarSaving
public $actor;
/**
* The path to the avatar that will be saved.
* The image that will be saved.
*
* @var string
* @var Image
*/
public $path;
public $image;
/**
* @param User $user The user whose avatar will be saved.
* @param User $actor The user performing the action.
* @param string $path The path to the avatar that will be saved.
* @param Image $image The image that will be saved.
*/
public function __construct(User $user, User $actor, $path)
public function __construct(User $user, User $actor, Image $image)
{
$this->user = $user;
$this->actor = $actor;
$this->path = $path;
$this->image = $image;
}
}

View File

@ -42,7 +42,7 @@ class EmailGambit extends AbstractRegexGambit
*/
public function apply(AbstractSearch $search, $bit)
{
if (! $search->getActor()->isAdmin()) {
if (! $search->getActor()->hasPermission('user.edit')) {
return false;
}
@ -60,8 +60,6 @@ class EmailGambit extends AbstractRegexGambit
$email = trim($matches[1], '"');
$user = $this->users->findByEmail($email);
$search->getQuery()->where('id', $negate ? '!=' : '=', $user->id);
$search->getQuery()->where('email', $negate ? '!=' : '=', $email);
}
}

View File

@ -15,16 +15,26 @@ use DomainException;
use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Event\ConfigureUserPreferences;
use Flarum\Event\GetDisplayName;
use Flarum\Event\PostWasDeleted;
use Flarum\Event\PrepareUserGroups;
use Flarum\Event\UserAvatarWasChanged;
use Flarum\Event\UserEmailChangeWasRequested;
use Flarum\Event\UserEmailWasChanged;
use Flarum\Event\UserPasswordWasChanged;
use Flarum\Event\UserWasActivated;
use Flarum\Event\UserWasDeleted;
use Flarum\Event\UserWasRegistered;
use Flarum\Event\UserWasRenamed;
use Flarum\Foundation\Application;
use Flarum\Foundation\EventGeneratorTrait;
use Flarum\Group\Group;
use Flarum\Group\Permission;
use Flarum\Http\UrlGenerator;
use Flarum\Notification\Notification;
use Flarum\Post\Event\Deleted as PostDeleted;
use Flarum\User\Event\Activated;
use Flarum\User\Event\AvatarChanged;
use Flarum\User\Event\BioChanged;
use Flarum\User\Event\CheckingPassword;
use Flarum\User\Event\Deleted;
use Flarum\User\Event\EmailChanged;
@ -42,7 +52,6 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
* @property bool $is_activated
* @property string $password
* @property string $locale
* @property string $bio
* @property string|null $avatar_path
* @property string $avatar_url
* @property array $preferences
@ -263,21 +272,6 @@ class User extends AbstractModel
$this->attributes['password'] = $value ? static::$hasher->make($value) : '';
}
/**
* Change the user's bio.
*
* @param string $bio
* @return $this
*/
public function changeBio($bio)
{
$this->bio = $bio;
$this->raise(new BioChanged($this));
return $this;
}
/**
* Mark all discussions as read.
*
@ -325,9 +319,23 @@ class User extends AbstractModel
*/
public function getAvatarUrlAttribute()
{
$urlGenerator = app('Flarum\Http\UrlGenerator');
if ($this->avatar_path) {
if (strpos($this->avatar_path, '://') !== false) {
return $this->avatar_path;
}
return $this->avatar_path ? $urlGenerator->to('forum')->path('assets/avatars/'.$this->avatar_path) : null;
return app(UrlGenerator::class)->toPath('assets/avatars/'.$this->avatar_path);
}
}
/**
* Get the user's display name.
*
* @return string
*/
public function getDisplayNameAttribute()
{
return static::$dispatcher->until(new GetDisplayName($this)) ?: $this->username;
}
/**

View File

@ -0,0 +1,12 @@
@extends('flarum.forum::layouts.basic')
@section('content')
<p>
{{ $message }}
</p>
<p>
<a href="{{ $app->url() }}">
{{ $translator->trans('core.views.error.404_return_link', ['{forum}' => $settings->get('forum_title')]) }}
</a>
</p>
@endsection

View File

@ -0,0 +1,7 @@
@extends('flarum.forum::layouts.basic')
@section('content')
<p>
{{ $message }}
</p>
@endsection

View File

@ -1,5 +1,3 @@
{!! array_get($forum, 'attributes.headerHtml') !!}
<div id="app" class="App">
<div id="app-navigation" class="App-navigation"></div>

View File

@ -50,6 +50,7 @@
}
app.boot(@json($payload));
@if (! $debug)
} catch (e) {
window.location += (window.location.search ? '&' : '?') + 'nojs=1';

View File

@ -8,7 +8,7 @@ $postsCount = count($discussion->relationships->posts->data);
<div>
@foreach ($posts as $post)
<div>
<?php $user = $getResource($post->relationships->user->data); ?>
<?php $user = ! empty($post->relationships->user->data) ? $getResource($post->relationships->user->data) : null; ?>
<h3>{{ $user ? $user->attributes->username : $translator->trans('core.lib.username.deleted_text') }}</h3>
<div class="Post-body">
{!! $post->attributes->contentHtml !!}

Some files were not shown because too many files have changed in this diff Show More