1
0
mirror of https://github.com/flarum/core.git synced 2025-08-24 00:53:27 +02:00

Compare commits

...

26 Commits

Author SHA1 Message Date
Alexander Skvortsov
984f751c71 Use process isolation for integration tests 2020-12-01 19:33:24 -05:00
flarum-bot
8830e9dd09 Bundled output for commit fe41bc1fdc [skip ci] 2020-12-01 16:22:59 +00:00
Alexander Skvortsov
fe41bc1fdc Remove Deprecated Beta14 Code (#2428) 2020-12-01 11:21:28 -05:00
Nina Pypchenko
5a763050a6 DRY up image uploading code (#2477) 2020-12-01 10:42:05 -05:00
Sami Mazouz
8c813bc340 ApiSerializer Extender (#2438) 2020-11-30 19:24:50 -05:00
flarum-bot
f67dee0a9e Bundled output for commit f968420216 [skip ci] 2020-11-30 19:02:41 +00:00
Alexander Skvortsov
f968420216 Don't use browser scroll restore in DiscussionPage (#2476)
Although native browser scroll restorations have become quite powerful, it interferes with Flarum's PostStream, so if we're on a DiscussionPage, we use manual scroll restoration.
2020-11-30 14:01:08 -05:00
flarum-bot
d5e124b4a2 Bundled output for commit 09e2736cbc [skip ci] 2020-11-29 23:34:50 +00:00
Alexander Skvortsov
09e2736cbc Fix goToIndex to visible end
In the PostStream, `this.visibleEnd` represents the index of the last post + 1, but `loadNearIndex` treated it as if it was the index of the last post. This means that executing `goToIndex` on the post stream's current `this.visiblePost` didn't load new posts, and as a result, the requested scrolling did not occur.
2020-11-29 18:33:29 -05:00
flarum-bot
ddb3d3edb0 Bundled output for commit 28d56f5fc8 [skip ci] 2020-11-29 22:47:21 +00:00
Alexander Skvortsov
28d56f5fc8 Merge pull request #2465 from flarum/0.1.0-beta.14.1 2020-11-29 17:45:58 -05:00
Alexander Skvortsov
9b4012bbb5 Reset dist js 2020-11-29 17:41:16 -05:00
Alexander Skvortsov
1a5e4d454e Move floodgate to middleware, add extender + integration tests (#2170) 2020-11-29 17:13:22 -05:00
sl-kr
387b4fd315 update a user's comment count if deleting a discussion (#2472) 2020-11-29 17:11:05 -05:00
flarum-bot
66482c2815 Bundled output for commit 277a5c3fac [skip ci] 2020-11-26 22:54:38 +00:00
Mohammad Reza
277a5c3fac Clear error alerts in change email modal on success (#2467) 2020-11-26 17:53:38 -05:00
Nina Pypchenko
286d8dec5b Update tsconfig file to include .tsx files (#2457) 2020-11-26 12:00:13 -05:00
flarum-bot
e1c61a0e85 Bundled output for commit 102e76b084 [skip ci] 2020-11-26 06:56:10 +00:00
Alexander Skvortsov
102e76b084 Defer clearing discussion list on discussion start
This prevents an edge case where `app.discussions` is considered empty while the new page is loading, and as a result, the side pane isn't set as "enabled". Then, if the pane has previously been pinned, when the page loads and the side pane appears, it covers up part of the discussion page.

Fixes https://github.com/flarum/core/issues/2471
2020-11-26 01:54:28 -05:00
flarum-bot
d09d4bc507 Bundled output for commit c3989cc952 [skip ci] 2020-11-24 17:46:02 +00:00
Charlie
c3989cc952 AdminUX Overhaul (#2409)
- Extensions now have their own pages
- The API for extensions to register permissions and settings has been overhauled via the `flarum/admin/utils/ExtensionData` util
- An extension grid has been added as a widget to the Dashboard page
2020-11-24 12:44:40 -05:00
Daniël Klabbers
967cd0e3ca update version constant for beta 14.1 2020-11-02 13:53:20 +01:00
Daniël Klabbers
b79152b977 bundled output for js changes beta 14.1 2020-11-02 11:53:27 +01:00
Daniël Klabbers
ace624db66 changelog for v0.1.0-beta.14.1 2020-11-02 11:51:24 +01:00
Alexander Skvortsov
9b9f2c4bb7 Fix exiting composer while in fullscreen mode. 2020-10-30 20:44:52 -04:00
Alexander Skvortsov
8b1de457bf Fix broken page title logic on subpath installs
The base path needs to be accounted for when calculating whether we're on the default route.
2020-10-30 14:18:09 -04:00
84 changed files with 2677 additions and 863 deletions

View File

@@ -1,5 +1,15 @@
# Changelog
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
### Fixed
- SuperTextarea component is not exported.
- Symfony dependencies do not match those depended on by Laravel (#2407)
- Scripts from textformatter aren't executed (#2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added

6
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,29 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import ExtensionPage from './components/ExtensionPage';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application {
// Deprecated as of beta 15
extensionSettings = {};
extensionData = new ExtensionData();
extensionCategories = {
discussion: 70,
moderation: 60,
feature: 50,
formatting: 40,
theme: 30,
authentication: 20,
language: 10,
other: 0,
};
history = {
canGoBack: () => true,
getPrevious: () => {},
@@ -34,7 +50,13 @@ export default class AdminApplication extends Application {
m.route.prefix = '#';
super.mount();
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('app-navigation'), {
view: () =>
Navigation.component({
className: 'App-backControl',
drawer: true,
}),
});
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
@@ -43,7 +65,7 @@ export default class AdminApplication extends Application {
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) {
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}

View File

@@ -1,17 +1,21 @@
import compat from '../common/compat';
import saveSettings from './utils/saveSettings';
import ExtensionData from './utils/ExtensionData';
import isExtensionEnabled from './utils/isExtensionEnabled';
import getCategorizedExtensions from './utils/getCategorizedExtensions';
import SettingDropdown from './components/SettingDropdown';
import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage';
import StatusWidget from './components/StatusWidget';
import ExtensionsWidget from './components/ExtensionsWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
import DashboardWidget from './components/DashboardWidget';
import AddExtensionModal from './components/AddExtensionModal';
import ExtensionsPage from './components/ExtensionsPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionLinkButton from './components/ExtensionLinkButton';
import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import MailPage from './components/MailPage';
@@ -23,6 +27,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
import AdminNav from './components/AdminNav';
import AdminHeader from './components/AdminHeader';
import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
@@ -30,17 +35,21 @@ import AdminApplication from './AdminApplication';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'utils/ExtensionData': ExtensionData,
'utils/isExtensionEnabled': isExtensionEnabled,
'utils/getCategorizedExtensions': getCategorizedExtensions,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/StatusWidget': StatusWidget,
'components/ExtensionsWidget': ExtensionsWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/ExtensionPage': ExtensionPage,
'components/ExtensionLinkButton': ExtensionLinkButton,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/MailPage': MailPage,
@@ -52,6 +61,7 @@ export default Object.assign(compat, {
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/AdminHeader': AdminHeader,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,

View File

@@ -0,0 +1,19 @@
import Component from '../../common/Component';
import classList from '../../common/utils/classList';
import icon from '../../common/helpers/icon';
export default class AdminHeader extends Component {
view(vnode) {
return [
<div className={classList(['AdminHeader', this.attrs.className])}>
<div className="container">
<h2>
{icon(this.attrs.icon)}
{vnode.children}
</h2>
<div className="AdminHeader-description">{this.attrs.description}</div>
</div>
</div>,
];
}
}

View File

@@ -1,28 +1,28 @@
/*
* 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 ExtensionLinkButton from './ExtensionLinkButton';
import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton';
import LinkButton from '../../common/components/LinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
export default class AdminNav extends Component {
oninit(vnode) {
super.oninit(vnode);
this.query = Stream('');
}
view() {
return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
{this.items().toArray().concat(this.extensionItems().toArray())}
</SelectDropdown>
);
}
/**
* Build an item list of links to show in the admin navigation.
* Build an item list of main links to show in the admin navigation.
*
* @return {ItemList}
*/
@@ -31,76 +31,90 @@ export default class AdminNav extends Component {
items.add(
'dashboard',
AdminLinkButton.component(
{
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
description: app.translator.trans('core.admin.nav.dashboard_text'),
},
app.translator.trans('core.admin.nav.dashboard_button')
)
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
{app.translator.trans('core.admin.nav.dashboard_button')}
</LinkButton>
);
items.add(
'basics',
AdminLinkButton.component(
{
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
description: app.translator.trans('core.admin.nav.basics_text'),
},
app.translator.trans('core.admin.nav.basics_button')
)
<LinkButton href={app.route('basics')} icon="fas fa-pencil-alt" title={app.translator.trans('core.admin.nav.basics_title')}>
{app.translator.trans('core.admin.nav.basics_button')}
</LinkButton>
);
items.add(
'mail',
AdminLinkButton.component(
{
href: app.route('mail'),
icon: 'fas fa-envelope',
description: app.translator.trans('core.admin.nav.email_text'),
},
app.translator.trans('core.admin.nav.email_button')
)
<LinkButton href={app.route('mail')} icon="fas fa-envelope" title={app.translator.trans('core.admin.nav.email_title')}>
{app.translator.trans('core.admin.nav.email_button')}
</LinkButton>
);
items.add(
'permissions',
AdminLinkButton.component(
{
href: app.route('permissions'),
icon: 'fas fa-key',
description: app.translator.trans('core.admin.nav.permissions_text'),
},
app.translator.trans('core.admin.nav.permissions_button')
)
<LinkButton href={app.route('permissions')} icon="fas fa-key" title={app.translator.trans('core.admin.nav.permissions_title')}>
{app.translator.trans('core.admin.nav.permissions_button')}
</LinkButton>
);
items.add(
'appearance',
AdminLinkButton.component(
{
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
description: app.translator.trans('core.admin.nav.appearance_text'),
},
app.translator.trans('core.admin.nav.appearance_button')
)
<LinkButton href={app.route('appearance')} icon="fas fa-paint-brush" title={app.translator.trans('core.admin.nav.appearance_title')}>
{app.translator.trans('core.admin.nav.appearance_button')}
</LinkButton>
);
items.add(
'extensions',
AdminLinkButton.component(
{
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
description: app.translator.trans('core.admin.nav.extensions_text'),
},
app.translator.trans('core.admin.nav.extensions_button')
)
'search',
<div className="Search-input">
<input
className="FormControl SearchBar"
bidi={this.query}
type="search"
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
/>
</div>
);
return items;
}
extensionItems() {
const items = new ItemList();
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
Object.keys(categorizedExtensions).map((category) => {
if (!this.query()) {
items.add(
category,
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
categories[category]
);
}
categorizedExtensions[category].map((extension) => {
const query = this.query().toUpperCase();
const title = extension.extra['flarum-extension'].title;
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
items.add(
extension.id,
<ExtensionLinkButton
href={app.route('extension', { id: extension.id })}
extensionId={extension.id}
className="ExtensionNavButton"
title={extension.description}
>
{title}
</ExtensionLinkButton>,
categories[category]
);
}
});
});
return items;
}
}

View File

@@ -7,6 +7,7 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
import AdminHeader from './AdminHeader';
export default class AppearancePage extends Page {
oninit(vnode) {
@@ -21,6 +22,13 @@ export default class AppearancePage extends Page {
view() {
return (
<div className="AppearancePage">
<AdminHeader
icon="fas fa-paint-brush"
description={app.translator.trans('core.admin.appearance.description')}
className="AppearancePage-header"
>
{app.translator.trans('core.admin.appearance.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors">

View File

@@ -7,6 +7,7 @@ import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import withAttr from '../../common/utils/withAttr';
import AdminHeader from './AdminHeader';
export default class BasicsPage extends Page {
oninit(vnode) {
@@ -49,6 +50,9 @@ export default class BasicsPage extends Page {
view() {
return (
<div className="BasicsPage">
<AdminHeader icon="fas fa-pencil-alt" description={app.translator.trans('core.admin.basics.description')} className="BasicsPage-header">
{app.translator.trans('core.admin.basics.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(

View File

@@ -1,16 +1,29 @@
import Page from '../../common/components/Page';
import StatusWidget from './StatusWidget';
import ExtensionsWidget from './ExtensionsWidget';
import AdminHeader from './AdminHeader';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<div className="container">{this.availableWidgets()}</div>
<AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
{app.translator.trans('core.admin.dashboard.title')}
</AdminHeader>
<div className="container">{this.availableWidgets().toArray()}</div>
</div>
);
}
availableWidgets() {
return [<StatusWidget />];
const items = new ItemList();
items.add('status', <StatusWidget />, 30);
items.add('extensions', <ExtensionsWidget />, 10);
return items;
}
}

View File

@@ -0,0 +1,29 @@
import isExtensionEnabled from '../utils/isExtensionEnabled';
import LinkButton from '../../common/components/LinkButton';
import icon from '../../common/helpers/icon';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionLinkButton extends LinkButton {
getButtonContent(children) {
const content = super.getButtonContent(children);
const extension = app.data.extensions[this.attrs.extensionId];
const statuses = this.statusItems(extension.id).toArray();
content.unshift(
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
);
content.push(statuses);
return content;
}
statusItems(name) {
const items = new ItemList();
items.add('enabled', <span class={'ExtensionListItem-Dot ' + (isExtensionEnabled(name) ? 'enabled' : 'disabled')} />);
return items;
}
}

View File

@@ -0,0 +1,376 @@
import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import LinkButton from '../../common/components/LinkButton';
import Page from '../../common/components/Page';
import Select from '../../common/components/Select';
import Switch from '../../common/components/Switch';
import icon from '../../common/helpers/icon';
import punctuateSeries from '../../common/helpers/punctuateSeries';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import LoadingModal from './LoadingModal';
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
import saveSettings from '../utils/saveSettings';
import ExtensionData from '../utils/ExtensionData';
import isExtensionEnabled from '../utils/isExtensionEnabled';
export default class ExtensionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.extension = app.data.extensions[this.attrs.id];
this.changingState = false;
this.settings = {};
this.infoFields = {
discuss: 'fas fa-comment-alt',
documentation: 'fas fa-book',
support: 'fas fa-life-ring',
website: 'fas fa-link',
donate: 'fas fa-donate',
};
// Backwards compatibility layer will be removed in
// Beta 16
if (app.extensionSettings[this.extension.id]) {
app.extensionData[this.extension.id] = app.extensionSettings[this.extension.id];
}
}
className() {
return this.extension.id + '-Page';
}
view() {
return (
<div className={'ExtensionPage ' + this.className()}>
{this.header()}
{!this.isEnabled() ? (
<div className="container">
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h2>
</div>
) : (
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
)}
</div>
);
}
header() {
return [
<div className="ExtensionPage-header">
<div className="container">
<div className="ExtensionTitle">
<span className="ExtensionIcon" style={this.extension.icon}>
{this.extension.icon ? icon(this.extension.icon.name) : ''}
</span>
<div className="ExtensionName">
<h2>{this.extension.extra['flarum-extension'].title}</h2>
</div>
<div className="ExtensionPage-headerTopItems">
<ul>{listItems(this.topItems().toArray())}</ul>
</div>
</div>
<div className="helpText">{this.extension.description}</div>
<div className="ExtensionPage-headerItems">
<Switch state={this.isEnabled()} onchange={this.toggle.bind(this, this.extension.id)}>
{this.isEnabled(this.extension.id)
? app.translator.trans('core.admin.extension.enabled')
: app.translator.trans('core.admin.extension.disabled')}
</Switch>
<aside className="ExtensionInfo">
<ul>{listItems(this.infoItems().toArray())}</ul>
</aside>
</div>
</div>
</div>,
];
}
sections() {
const items = new ItemList();
items.add('content', this.content());
items.add('permissions', [
<div className="ExtensionPage-permissions">
<div className="ExtensionPage-permissions-header">
<div className="container">
<h2 className="ExtensionTitle">{app.translator.trans('core.admin.extension.permissions_title')}</h2>
</div>
</div>
<div className="container">
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
ExtensionPermissionGrid.component({ extensionId: this.extension.id })
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h2>
)}
</div>
</div>,
]);
return items;
}
content() {
const settings = app.extensionData.getSettings(this.extension.id);
return (
<div className="ExtensionPage-settings">
<div className="container">
{typeof app.extensionData[this.extension.id] === 'function' ? (
<Button onclick={app.extensionData[this.extension.id].bind(this)} className="Button Button--primary">
{app.translator.trans('core.admin.extension.open_modal')}
</Button>
) : settings ? (
<div className="Form">
{settings.map(this.buildSettingComponent.bind(this))}
<div className="Form-group">{this.submitButton()}</div>
</div>
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h2>
)}
</div>
</div>
);
}
topItems() {
const items = new ItemList();
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
if (!this.isEnabled()) {
const uninstall = () => {
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
}
};
items.add(
'uninstall',
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
{app.translator.trans('core.admin.extension.uninstall_button')}
</Button>
);
}
return items;
}
infoItems() {
const items = new ItemList();
if (this.extension.authors) {
let authors = [];
Object.keys(this.extension.authors).map((author, i) => {
const link = this.extension.authors[author].homepage
? this.extension.authors[author].homepage
: 'mailto:' + this.extension.authors[author].email;
authors.push(
<Link href={link} external={true} target="_blank">
{this.extension.authors[author].name}
</Link>
);
});
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
}
const infoData = {};
if (this.extension.source || this.extension.support) {
infoData.source = {
icon: 'fas fa-code',
href: this.extension.source ? this.extension.source.url : this.extension.support.source,
};
}
Object.keys(this.infoFields).map((field) => {
const info = this.extension.extra['flarum-extension'].info;
if (info && info[field]) {
infoData[field] = {
icon: this.infoFields[field],
href: info[field],
};
}
});
Object.entries(infoData).map(([field, value]) => {
items.add(
field,
<LinkButton href={value.href} icon={value.icon} external={true} target="_blank">
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
</LinkButton>
);
});
return items;
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
/**
* getSetting takes a settings object and turns it into a component.
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type.
*
* @example
*
* {
* setting: 'acme.checkbox',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'bool'
* }
*
* @example
*
* {
* setting: 'acme.select',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'select',
* options: {
* 'option1': 'Option 1 label',
* 'option2': 'Option 2 label',
* },
* default: 'option1',
* }
*
* @param setting
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
const setting = entry.setting;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
return (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]}>
{entry.label}
</Switch>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
return (
<div className="Form-group">
<label>{entry.label}</label>
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} />
</div>
);
} else {
return (
<div className="Form-group">
<label>{entry.label}</label>
<input type={entry.type} className="FormControl" bidi={this.setting(setting)} />
</div>
);
}
}
toggle() {
const enabled = this.isEnabled();
this.changingState = true;
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'PATCH',
body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', this.extension.id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
isChanged() {
return Object.keys(this.dirty()).length;
}
saveSettings(e) {
e.preventDefault();
app.alerts.clear();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
onsaved() {
this.loading = false;
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.extension.saved_message'));
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key];
}
isEnabled() {
let isEnabled = isExtensionEnabled(this.extension.id);
return this.changingState ? !isEnabled : isEnabled;
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
if (e.status !== 409) {
throw e;
}
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}
}

View File

@@ -0,0 +1,39 @@
import PermissionGrid from './PermissionGrid';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionPermissionGrid extends PermissionGrid {
oninit(vnode) {
super.oninit(vnode);
this.extensionId = this.attrs.extensionId;
}
permissionItems() {
const permissionCategories = super.permissionItems();
permissionCategories.items = Object.entries(permissionCategories.items)
.filter(([category, info]) => info.content.children.length > 0)
.reduce((obj, [category, info]) => {
obj[category] = info;
return obj;
}, {});
return permissionCategories;
}
viewItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
}
startItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
}
replyItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
}
moderateItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
}
}

View File

@@ -1,158 +0,0 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class ExtensionsPage extends Page {
view() {
return (
<div className="ExtensionsPage">
<div className="ExtensionsPage-header">
<div className="container">
{Button.component(
{
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
},
app.translator.trans('core.admin.extensions.add_button')
)}
</div>
</div>
<div className="ExtensionsPage-list">
<div className="container">
<ul className="ExtensionList">
{Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
const controls = this.controlItems(extension.id).toArray();
return (
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
{controls.length ? (
<Dropdown
className="ExtensionListItem-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{controls}
</Dropdown>
) : (
''
)}
<div className="ExtensionListItem-main">
<label className="ExtensionListItem-title">
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
{extension.extra['flarum-extension'].title}
</label>
<div className="ExtensionListItem-version">{extension.version}</div>
<div className="ExtensionListItem-description">{extension.description}</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
);
}
controlItems(name) {
const items = new ItemList();
const enabled = this.isEnabled(name);
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component(
{
icon: 'fas fa-cog',
onclick: app.extensionSettings[name],
},
app.translator.trans('core.admin.extensions.settings_button')
)
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component(
{
icon: 'far fa-trash-alt',
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
},
},
app.translator.trans('core.admin.extensions.uninstall_button')
)
);
}
return items;
}
isEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.indexOf(name) !== -1;
}
toggle(id) {
const enabled = this.isEnabled(id);
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
if (e.status !== 409) {
throw e;
}
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}
}

View File

@@ -0,0 +1,48 @@
import DashboardWidget from './DashboardWidget';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon';
export default class ExtensionsWidget extends DashboardWidget {
className() {
return 'ExtensionsWidget';
}
content() {
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
return (
<div className="ExtensionsWidget-list">
<div className="container">
{Object.keys(categories).map((category) => {
if (categorizedExtensions[category]) {
return (
<div className="ExtensionList-Category">
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
<ul className="ExtensionList">
{categorizedExtensions[category].map((extension) => {
return (
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
<Link href={app.route('extension', { id: extension.id })}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
</div>
</Link>
</li>
);
})}
</ul>
</div>
);
}
})}
</div>
</div>
);
}
}

View File

@@ -1,4 +1,5 @@
import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
@@ -19,6 +20,13 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add(
'help',
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
{app.translator.trans('core.admin.header.get_help')}
</LinkButton>
);
items.add('session', SessionDropdown.component());
return items;

View File

@@ -6,6 +6,8 @@ import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import Stream from '../../common/utils/Stream';
import icon from '../../common/helpers/icon';
import AdminHeader from './AdminHeader';
export default class MailPage extends Page {
oninit(vnode) {
@@ -65,11 +67,11 @@ export default class MailPage extends Page {
return (
<div className="MailPage">
<AdminHeader icon="fas fa-envelope" description={app.translator.trans('core.admin.email.description')} className="MailPage-header">
{app.translator.trans('core.admin.email.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),

View File

@@ -6,12 +6,6 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray();
}
view() {
const scopes = this.scopeItems().toArray();
@@ -35,25 +29,27 @@ export default class PermissionGrid extends Component {
<th>{this.scopeControlItems().toArray()}</th>
</tr>
</thead>
{this.permissions.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
{this.permissionItems()
.toArray()
.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
))}
</tbody>
))}
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
</table>
);
}
@@ -158,6 +154,8 @@ export default class PermissionGrid extends Component {
permission: 'user.viewLastSeenAt',
});
items.merge(app.extensionData.getAllExtensionPermissions('view'));
return items;
}
@@ -198,6 +196,8 @@ export default class PermissionGrid extends Component {
90
);
items.merge(app.extensionData.getAllExtensionPermissions('start'));
return items;
}
@@ -238,6 +238,8 @@ export default class PermissionGrid extends Component {
90
);
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
return items;
}
@@ -334,6 +336,8 @@ export default class PermissionGrid extends Component {
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
return items;
}

View File

@@ -4,11 +4,15 @@ import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid';
import AdminHeader from './AdminHeader';
export default class PermissionsPage extends Page {
view() {
return (
<div className="PermissionsPage">
<AdminHeader icon="fas fa-key" description={app.translator.trans('core.admin.permissions.description')} className="PermissionsPage-header">
{app.translator.trans('core.admin.permissions.title')}
</AdminHeader>
<div className="PermissionsPage-groups">
<div className="container">
{app.store

View File

@@ -0,0 +1,19 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
/**
* A custom route resolver for ExtensionPage that generates handles routes
* to default extension pages or a page provided by an extension.
*/
export default class ExtensionPageResolver extends DefaultResolver {
static extension: string | null = null;
onmatch(args, requestedPath, route) {
const extensionPage = app.extensionData.getPage(args.id);
if (extensionPage) {
return extensionPage;
}
return super.onmatch(args, requestedPath, route);
}
}

View File

@@ -2,8 +2,9 @@ import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
/**
* The `routes` initializer defines the forum app's routes.
@@ -16,7 +17,7 @@ export default function (app) {
basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
};
}

View File

@@ -0,0 +1,167 @@
import ItemList from '../../common/utils/ItemList';
export default class ExtensionData {
constructor() {
this.data = {};
this.currentExtension = null;
}
/**
* This function simply takes the extension id
*
* @example
* app.extensionData.load('flarum-tags')
*
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
*
* @param extension
*/
for(extension) {
this.currentExtension = extension;
this.data[extension] = this.data[extension] || {};
return this;
}
/**
* This function registers your settings with Flarum
*
* @example
*
* .registerSetting({
* setting: 'flarum-flags.guidelines_url',
* type: 'text', // This will be inputted into the input tag for the setting (text/number/etc)
* label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label')
* }, 15) // priority is optional (ItemList)
*
*
* @param content
* @param priority
* @returns {ExtensionData}
*/
registerSetting(content, priority = 0) {
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
this.data[this.currentExtension].settings.add(content.setting, content, priority);
return this;
}
/**
* This function registers your permission with Flarum
*
* @example
*
* .registerPermission('permissions', {
* icon: 'fas fa-flag',
* label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
* permission: 'discussion.viewFlags'
* }, 'moderate', 65)
*
* @param content
* @param permissionType
* @param priority
* @returns {ExtensionData}
*/
registerPermission(content, permissionType = null, priority = 0) {
this.data[this.currentExtension].permissions = this.data[this.currentExtension].permissions || {};
if (!this.data[this.currentExtension].permissions[permissionType]) {
this.data[this.currentExtension].permissions[permissionType] = new ItemList();
}
this.data[this.currentExtension].permissions[permissionType].add(content.permission, content, priority);
return this;
}
/**
* Replace the default extension page with a custom component.
* This component would typically extend ExtensionPage
*
* @param component
* @returns {ExtensionData}
*/
registerPage(component) {
this.data[this.currentExtension].page = component;
return this;
}
/**
* Get an extension's registered settings
*
* @param extensionId
* @returns {boolean|*}
*/
getSettings(extensionId) {
if (this.data[extensionId] && this.data[extensionId].settings) {
return this.data[extensionId].settings.toArray();
}
return false;
}
/**
*
* Get an ItemList of all extensions' registered permissions
*
* @param extension
* @param type
* @returns {ItemList}
*/
getAllExtensionPermissions(type) {
const items = new ItemList();
Object.keys(this.data).map((extension) => {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
items.merge(this.data[extension].permissions[type]);
}
});
return items;
}
/**
* Get a singular extension's registered permissions
*
* @param extension
* @param type
* @returns {boolean|*}
*/
getExtensionPermissions(extension, type) {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
return this.data[extension].permissions[type];
}
return new ItemList();
}
/**
* Checks whether a given extension has registered permissions.
*
* @param extension
* @returns {boolean}
*/
extensionHasPermissions(extension) {
if (this.data[extension] && this.data[extension].permissions) {
return true;
}
return false;
}
/**
* Returns an extension's custom page component if it exists.
*
* @param extension
* @returns {boolean|*}
*/
getPage(extension) {
if (this.data[extension]) {
return this.data[extension].page;
}
return false;
}
}

View File

@@ -0,0 +1,25 @@
export default function getCategorizedExtensions() {
let extensions = {};
Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
let category = extension.extra['flarum-extension'].category;
// Wrap languages packs into new system
if (extension.extra['flarum-locale']) {
category = 'language';
}
if (category in app.extensionCategories) {
extensions[category] = extensions[category] || [];
extensions[category].push(extension);
} else {
extensions.other = extensions.other || [];
extensions.other.push(extension);
}
});
return extensions;
}

View File

@@ -0,0 +1,5 @@
export default function isExtensionEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.includes(name);
}

View File

@@ -270,7 +270,7 @@ export default class Application {
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}

View File

@@ -1,8 +1,5 @@
import * as Mithril from 'mithril';
let deprecatedPropsWarned = false;
let deprecatedInitPropsWarned = false;
export interface ComponentAttrs extends Mithril.Attributes {}
/**
@@ -131,38 +128,5 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {
// Deprecated, part of Mithril 2 BC layer
if ('initProps' in this && !deprecatedInitPropsWarned) {
deprecatedInitPropsWarned = true;
console.warn('initProps is deprecated, please use initAttrs instead.');
(this as any).initProps(attrs);
}
}
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*
* @deprecated, use attrs instead.
*/
get props() {
if (!deprecatedPropsWarned) {
deprecatedPropsWarned = true;
console.warn('this.props is deprecated, please use this.attrs instead.');
}
return this.attrs;
}
set props(props) {
if (!deprecatedPropsWarned) {
deprecatedPropsWarned = true;
console.warn('this.props is deprecated, please use this.attrs instead.');
}
this.attrs = props;
}
// END DEPRECATED MITHRIL 2 BC LAYER
protected static initAttrs<T>(attrs: T): void {}
}

View File

@@ -35,6 +35,11 @@ export default class Button extends Component {
attrs['aria-label'] = attrs.title;
}
// If given a translation object, extract the text.
if (typeof attrs.title === 'object') {
attrs.title = extractText(attrs.title);
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && vnode.children) {
attrs.title = extractText(vnode.children);

View File

@@ -29,6 +29,13 @@ export default class Page extends Component {
* @type {Boolean}
*/
this.scrollTopOnCreate = true;
/**
* Whether the browser should restore scroll state on refreshes.
*
* @type {Boolean}
*/
this.useBrowserScrollRestoration = true;
}
oncreate(vnode) {
@@ -41,6 +48,10 @@ export default class Page extends Component {
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
if ('scrollRestoration' in history) {
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
}
}
onremove() {

View File

@@ -12,6 +12,9 @@ import icon from '../helpers/icon';
function isActive(vnode) {
const tag = vnode.tag;
// Allow non-selectable dividers/headers to be added.
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
import 'expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';

View File

@@ -1,9 +1,3 @@
import withAttr from './withAttr';
import Stream from './Stream';
let deprecatedMPropWarned = false;
let deprecatedMWithAttrWarned = false;
export default function patchMithril(global) {
const defaultMithril = global.m;
@@ -22,23 +16,5 @@ export default function patchMithril(global) {
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
modifiedMithril.prop = function (...args) {
if (!deprecatedMPropWarned) {
deprecatedMPropWarned = true;
console.warn('m.prop() is deprecated, please use the Stream util (flarum/utils/Streams) instead.');
}
return Stream.bind(this)(...args);
};
modifiedMithril.withAttr = function (...args) {
if (!deprecatedMWithAttrWarned) {
deprecatedMWithAttrWarned = true;
console.warn("m.withAttr() is deprecated, please use flarum's withAttr util (flarum/utils/withAttr) instead.");
}
return withAttr.bind(this)(...args);
};
// END DEPRECATED MITHRIL 2 BC LAYER
global.m = modifiedMithril;
}

View File

@@ -90,11 +90,6 @@ export default class ForumApplication extends Application {
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({}, this);
/**
* @deprecated beta 14, remove in beta 15.
*/
this.cache.discussionList = this.discussions;
}
/**

View File

@@ -118,7 +118,10 @@ export default class ChangeEmailModal extends Modal {
meta: { password: this.password() },
}
)
.then(() => (this.success = true))
.then(() => {
this.success = true;
this.alertAttrs = null;
})
.catch(() => {})
.then(this.loaded.bind(this));
}

View File

@@ -199,7 +199,7 @@ export default class Composer extends Component {
*/
animatePositionChange() {
// When exiting full-screen mode: focus content
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {
this.focus();
return;
}

View File

@@ -44,12 +44,6 @@ export default class ComposerBody extends Component {
}
this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.composer.fields.content;
this.editor = this.composer;
}
view() {

View File

@@ -100,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.save(data)
.then((discussion) => {
this.composer.hide();
app.discussions.refresh();
app.discussions.refresh({ deferClear: true });
m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this));
}

View File

@@ -18,6 +18,8 @@ export default class DiscussionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.useBrowserScrollRestoration = false;
/**
* The discussion that is being viewed.
*

View File

@@ -34,11 +34,6 @@ class ComposerState {
this.editor = null;
this.clear();
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.component = this;
}
/**
@@ -77,12 +72,6 @@ class ComposerState {
this.fields = {
content: Stream(''),
};
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.fields.content;
this.value = this.fields.content;
}
/**

View File

@@ -172,7 +172,7 @@ class PostStreamState {
* @return {Promise}
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
if (index >= this.visibleStart && index < this.visibleEnd) {
return Promise.resolve();
}

View File

@@ -1,5 +1,5 @@
{
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"files": ["shims.d.ts"],
"compilerOptions": {
"allowUmdGlobalAccess": true,

View File

@@ -1,10 +1,12 @@
@import "common/common";
@import "admin/AdminHeader";
@import "admin/AdminNav";
@import "admin/DashboardPage";
@import "admin/BasicsPage";
@import "admin/PermissionsPage";
@import "admin/EditGroupModal";
@import "admin/ExtensionsPage";
@import "admin/ExtensionPage";
@import "admin/ExtensionWidget";
@import "admin/AppearancePage";
@import "admin/MailPage";

View File

@@ -0,0 +1,19 @@
.AdminHeader {
background: @control-bg;
margin-bottom: 20px;
padding: 20px 0;
h2 {
margin-top: 0;
margin-bottom: 10px;
color: @muted-color;
}
.AdminHeader-description {
margin: 0;
}
.icon {
margin-right: 15px;
}
}

View File

@@ -1,17 +1,85 @@
@admin-pane-width: 300px;
@admin-pane-width: 250px;
.App {
padding-bottom: 0;
}
.AdminLinkButton-description {
display: none;
}
.AdminContent {
padding: 20px 0;
}
.App-content .sideNavOffset {
margin-top: 0;
}
.Header-controls {
> li {
margin-left: 10px;
}
}
@media @phone {
.Dropdown-menu {
height: 70vh;
.item-search {
margin: 10px;
.SearchBar {
width: 100%
}
}
}
.ExtensionNavButton {
.Button-label {
margin-left: 30px;
}
.ExtensionIcon {
margin: 0 0 0 -4px !important;
}
}
}
@media @tablet {
.item-search{
display: none;
}
.ExtensionItem, .item-search {
display: none !important;
}
.ExtensionListTitle {
display: none !important;
}
}
@media @phone, @tablet {
.App-nav .AdminNav {
.Dropdown-menu {
> li {
.ExtensionListTitle {
color: @muted-color;
text-transform: uppercase;
margin: 25px 0 10px 15px;
}
.ExtensionIcon {
margin: -2px -29px;
width: 25px;
height: 25px;
font-size: 12.5px;
.icon {
margin: 0;
}
}
}
}
}
}
@media @desktop, @desktop-hd {
.App-nav {
position: absolute;
@@ -20,60 +88,84 @@
width: @admin-pane-width;
.box-shadow(0 6px 6px @shadow-color);
background: @body-bg;
border-top: 1px solid @control-bg;
z-index: @zindex-pane;
overflow: auto;
overflow-y: scroll;
padding-bottom: 40px;
.affix & {
position: fixed;
bottom: 0;
height: auto;
}
}
.App-content .sideNavOffset {
margin-left: @admin-pane-width;
}
.App-nav .AdminNav {
.Dropdown-menu > li {
> a {
padding: 15px 15px 15px 45px;
display: block;
text-decoration: none;
white-space: normal;
.Dropdown-menu {
.item-search {
margin-top: 10px;
margin-bottom: 20px;
}
> a, > a:hover, &.active > a {
color: @muted-color;
}
> a:hover {
background: @control-bg;
}
&.active > a {
background: @control-bg;
font-weight: normal;
.Button-label, .Button-icon {
> li {
> a {
padding: 10px 10px 10px 45px;
display: block;
text-decoration: none;
}
> a,
> a:hover,
&.active > a {
color: @text-color;
font-weight: bold;
}
> a:hover {
background: @control-bg;
}
&.active > a {
background: @primary-color;
font-weight: normal;
.Button-label,
.Button-icon {
color: @body-bg;
font-weight: bold;
}
}
.Button-icon {
float: left;
font-size: 13px !important;
margin-left: -25px !important;
margin-top: 4px !important;
}
.Button-label {
padding-left: 5px;
font-size: 14px;
font-weight: normal;
}
.Search-input,
.SearchBar {
max-width: 215px;
margin: 0 auto;
}
.ExtensionListTitle {
color: @muted-color;
text-transform: uppercase;
margin: 25px 0 15px 15px;
}
.ExtensionIcon {
width: 25px;
height: 25px;
font-size: 15px;
margin-left: -29px;
vertical-align: middle;
}
}
}
.Button-icon {
float: left;
margin-left: -30px;
font-size: 14px;
margin-top: 4px !important;
}
.Button-label {
display: block;
font-size: 15px;
font-weight: normal;
margin: 0 0 5px;
}
.AdminLinkButton-description {
display: block;
font-size: 12px;
}
}
.container {
width: 100%;
margin: 0;
@@ -85,4 +177,33 @@
padding: 0;
}
}
}
.ExtensionListItem-Dot {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
right: 13px;
margin: 6px 5px;
position: absolute;
}
.ExtensionNavButton {
.Button-label {
display: inline-block;
max-width: calc(100% - 18px);
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
margin-left: 5px;
}
}
.ExtensionListItem-Dot.enabled {
background-color: #2ECC40;
}
.ExtensionListItem-Dot.disabled {
background-color: #FF4136;
}

View File

@@ -1,8 +1,8 @@
.AppearancePage {
@media @desktop-up {
.container {
max-width: 600px;
padding: 30px;
margin: 0;
}
}

View File

@@ -1,5 +1,4 @@
.BasicsPage {
padding: 20px 0;
@media @desktop-up {
.container {

View File

@@ -1,18 +1,11 @@
.DashboardPage {
background: @control-bg;
background: @body-bg;
color: @control-color;
min-height: 100vh;
@media @desktop-up {
.container {
padding: 30px;
margin: 0;
}
}
}
.DashboardWidget {
background: @body-bg;
.Widget {
background: @control-bg;
color: @text-color;
border-radius: @border-radius;
padding: 20px;

View File

@@ -0,0 +1,153 @@
.ExtensionPage {
min-height: 110vh;
.ExtensionPage-header {
.ExtensionTitle {
display: flex;
align-items: center;
flex-wrap: wrap;
margin: 20px 0 15px;
}
.helpText {
margin-bottom: 5px;
}
h2 {
display: inline-block;
margin: 0;
vertical-align: middle;
}
}
.ExtensionPage-header,
.ExtensionPage-permissions-header {
background: @control-bg;
h2 {
color: @muted-color;
span {
font-size: 13px;
color: @muted-color;
font-weight: normal;
}
}
.Button-icon {
display: unset;
}
ul {
display: flex;
align-items: center;
list-style-type: none;
padding: 0;
margin: 0;
> li {
display: inline;
color: @muted-color;
margin-left: 13px;
> a {
color: @muted-color;
}
> .icon {
margin-right: 5px;
}
}
}
.ExtensionPage-headerItems {
padding: 15px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
.Checkbox {
margin: 5px 0 0 0;
display: inline-block;
}
}
.Checkbox.off {
.Checkbox-display {
background: @muted-more-color;
}
}
.ExtensionInfo {
margin-left: auto;
.item-authors {
a {
color: @muted-color;
}
}
}
.ExtensionName {
display: inline-block;
margin-left: 8px;
}
.ExtensionIcon {
width: 30px;
height: 30px;
font-size: 15px;
margin-left: 0;
vertical-align: middle;
}
.ExtensionPage-headerTopItems {
margin-left: auto;
}
@media (max-width: @screen-phone-max) {
.ExtensionPage-headerTopItems {
float: right;
position: relative;
}
.item-website, .item-source, .item-documentation {
display: none;
}
}
}
.ExtensionPage-settings {
margin-top: 20px;
padding: 10px 0;
input {
max-width: 400px;
}
}
.ExtensionPage-subHeader {
color: @muted-color;
font-weight: normal;
text-align: center;
}
.ExtensionPage-permissions {
@media @phone {
> .container {
overflow-x: scroll;
padding-bottom: 20px;
}
}
.ExtensionPage-permissions-header {
margin: 20px 0 20px;
padding: 5px 0;
}
}
}

View File

@@ -0,0 +1,93 @@
.ExtensionsWidget {
background-color: @body-bg;
padding: 0;
}
.ExtensionsWidget-list {
> .container {
padding: 0;
background-color: @body-bg;
.ExtensionList-Category {
background: @control-bg;
padding: 20px 0 20px 20px;
margin-bottom: 20px;
border-radius: @border-radius;
.ExtensionList-Label {
margin-top: 0;
color: @muted-color;
}
}
.ExtensionGroup {
margin-bottom: 20px;
h3 {
color: @muted-color;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 10px;
}
}
.ExtensionList {
padding: 0;
list-style: none;
display: grid;
grid-gap: 10px;
grid-template-columns: repeat(auto-fit, 90px);
margin-bottom: 0;
> li {
text-align: left;
position: relative;
display: block;
}
}
}
.ExtensionListItem.disabled {
.ExtensionListItem-title {
opacity: 0.5;
color: @muted-color;
}
.ExtensionListItem-icon {
opacity: 0.5;
}
}
.ExtensionListItem {
transition: .15s ease-in-out;
&:hover {
transform: scale(1.05);
}
.ExtensionListItem-title {
display: block;
text-align: center;
margin-top: 5px;
color: @text-color;
}
a:hover {
text-decoration: none;
}
}
}
.ExtensionIcon {
width: 90px;
height: 90px;
background: @control-bg;
color: @control-color;
border-radius: 6px;
display: inline-flex;
font-size: 45px;
text-align: center;
align-items: center;
justify-content: center;
vertical-align: middle;
}

View File

@@ -1,115 +0,0 @@
@extension-list-column-width: 410px;
.ExtensionsPage-header {
padding: 20px 0;
background: @control-bg;
}
.ExtensionsPage-list {
padding: 30px 0;
}
.ExtensionGroup {
margin-bottom: 20px;
h3 {
color: @muted-color;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 10px;
}
}
.ExtensionList {
columns: 3;
column-width: @extension-list-column-width;
margin: 0;
padding: 0;
list-style: none;
.clearfix();
> li {
-webkit-column-break-inside: avoid;
break-inside: avoid-column;
page-break-inside: avoid;
text-align: left;
position: relative;
border-radius: 4px;
transition: background .2s;
}
}
.ExtensionListItem.disabled {
.ExtensionListItem-title {
opacity: 0.5;
color: @muted-color;
}
.ExtensionListItem-icon {
opacity: 0.5;
}
}
.ExtensionListItem {
padding: 10px;
}
.ExtensionListItem:hover {
background: @control-bg;
}
.ExtensionListItem-content {
padding: 0 50px;
min-height: 40px;
}
.ExtensionListItem-main {
overflow: hidden;
text-overflow: ellipsis;
}
.ExtensionListItem-title {
display: inline-block;
font-size: 13px;
font-weight: bold;
white-space: nowrap;
cursor: pointer;
padding-right: 10px;
}
.ExtensionListItem-version {
color: @muted-more-color;
font-size: 11px;
font-weight: normal;
display: inline-flex;
}
.ExtensionListItem-controls {
float: right;
display: none;
margin-right: -50px;
margin-top: 1px;
.ExtensionListItem:hover &, &.open {
display: block;
}
}
.ExtensionListItem-description {
font-size: 11px;
font-weight: normal;
text-align: justify;
}
.ExtensionIcon {
width: 40px;
height: 40px;
background: @control-bg;
color: @control-color;
border-radius: 6px;
display: inline-block;
font-size: 20px;
line-height: 40px;
text-align: center;
margin-left: -50px;
position: absolute;
}
@media (max-width: @extension-list-column-width) {
.ExtensionListItem-description {
display: none;
}
.ExtensionListItem-version {
display: block;
margin-top: 8px;
}
}

View File

@@ -1,5 +1,4 @@
.MailPage {
padding: 20px 0;
@media @desktop-up {
.container {

View File

@@ -1,6 +1,11 @@
.PermissionsPage-groups {
background: @control-bg;
padding: 20px 0;
border-radius: @border-radius;
max-width: calc(~'100% - 60px');
display: block;
margin-left: 30px;
overflow-x: auto;
padding: 8px 0 8px;
}
.Group {
width: 90px;

View File

@@ -42,6 +42,20 @@ class ApiServiceProvider extends AbstractServiceProvider
return $routes;
});
$this->app->singleton('flarum.api.throttlers', function () {
return [
'bypassThrottlingAttribute' => function ($request) {
if ($request->getAttribute('bypassThrottling')) {
return false;
}
}
];
});
$this->app->bind(Middleware\ThrottleApi::class, function ($app) {
return new Middleware\ThrottleApi($app->make('flarum.api.throttlers'));
});
$this->app->singleton('flarum.api.middleware', function () {
return [
'flarum.api.error_handler',
@@ -53,7 +67,8 @@ class ApiServiceProvider extends AbstractServiceProvider
HttpMiddleware\AuthenticateWithHeader::class,
HttpMiddleware\SetLocale::class,
'flarum.api.route_resolver',
HttpMiddleware\CheckCsrfToken::class
HttpMiddleware\CheckCsrfToken::class,
Middleware\ThrottleApi::class
];
});

View File

@@ -64,6 +64,9 @@ class CreateDiscussionController extends AbstractCreateController
$actor = $request->getAttribute('actor');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}

View File

@@ -65,6 +65,9 @@ class CreatePostController extends AbstractCreateController
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}

View File

@@ -9,69 +9,36 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use Intervention\Image\ImageManager;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Psr\Http\Message\UploadedFileInterface;
class UploadFaviconController extends ShowForumController
class UploadFaviconController extends UploadImageController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $filePathSettingKey = 'favicon_path';
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
protected $filenamePrefix = 'favicon';
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
protected function makeImage(UploadedFileInterface $file): Image
{
$request->getAttribute('actor')->assertAdmin();
$this->fileExtension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$file = Arr::get($request->getUploadedFiles(), 'favicon');
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
if ($extension === 'ico') {
$image = $file->getStream();
if ($this->fileExtension === 'ico') {
$encodedImage = $file->getStream();
} else {
$manager = new ImageManager;
$manager = new ImageManager();
$image = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$encodedImage = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})->encode('png');
$extension = 'png';
$this->fileExtension = 'png';
}
if (($path = $this->settings->get('favicon_path')) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = 'favicon-'.Str::lower(Str::random(8)).'.'.$extension;
$this->uploadDir->write($uploadName, $image);
$this->settings->set('favicon_path', $uploadName);
return parent::data($request, $document);
return $encodedImage;
}
}

View File

@@ -0,0 +1,87 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Tobscure\JsonApi\Document;
abstract class UploadImageController extends ShowForumController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @var string
*/
protected $fileExtension = 'png';
/**
* @var string
*/
protected $filePathSettingKey = '';
/**
* @var string
*/
protected $filenamePrefix = '';
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
{
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), $this->filenamePrefix);
$encodedImage = $this->makeImage($file);
if (($path = $this->settings->get($this->filePathSettingKey)) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = $this->filenamePrefix.'-'.Str::lower(Str::random(8)).'.'.$this->fileExtension;
$this->uploadDir->write($uploadName, $encodedImage);
$this->settings->set($this->filePathSettingKey, $uploadName);
return parent::data($request, $document);
}
/**
* @param UploadedFileInterface $file
* @return Image
*/
abstract protected function makeImage(UploadedFileInterface $file): Image;
}

View File

@@ -9,61 +9,27 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use Intervention\Image\ImageManager;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Psr\Http\Message\UploadedFileInterface;
class UploadLogoController extends ShowForumController
class UploadLogoController extends UploadImageController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $filePathSettingKey = 'logo_path';
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
protected $filenamePrefix = 'logo';
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
protected function makeImage(UploadedFileInterface $file): Image
{
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), 'logo');
$manager = new ImageManager;
$manager = new ImageManager();
$encodedImage = $manager->make($file->getStream())->heighten(60, function ($constraint) {
$constraint->upsize();
})->encode('png');
if (($path = $this->settings->get('logo_path')) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = 'logo-'.Str::lower(Str::random(8)).'.png';
$this->uploadDir->write($uploadName, $encodedImage);
$this->settings->set('logo_path', $uploadName);
return parent::data($request, $document);
return $encodedImage;
}
}

View File

@@ -17,6 +17,8 @@ use Flarum\Api\Serializer\AbstractSerializer;
*
* This event is fired when a serializer is constructing an array of resource
* attributes for API output.
*
* @deprecated in beta 15, removed in beta 16
*/
class Serializing
{

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Middleware;
use Flarum\Post\Exception\FloodingException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface as Middleware;
use Psr\Http\Server\RequestHandlerInterface as Handler;
class ThrottleApi implements Middleware
{
protected $throttlers;
public function __construct(array $throttlers)
{
$this->throttlers = $throttlers;
}
public function process(Request $request, Handler $handler): Response
{
if ($this->throttle($request)) {
throw new FloodingException;
}
return $handler->handle($request);
}
/**
* @return bool
*/
public function throttle(Request $request): bool
{
$throttle = false;
foreach ($this->throttlers as $throttler) {
$result = $throttler($request);
// Explicitly returning false overrides all throttling.
// Explicitly returning true marks the request to be throttled.
// Anything else is ignored.
if ($result === false) {
return false;
} elseif ($result === true) {
$throttle = true;
}
}
return $throttle;
}
}

View File

@@ -16,6 +16,7 @@ use Flarum\Event\GetApiRelationship;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use LogicException;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -47,6 +48,16 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected static $container;
/**
* @var callable[]
*/
protected static $mutators = [];
/**
* @var array
*/
protected static $customRelations = [];
/**
* @return Request
*/
@@ -83,6 +94,18 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
$attributes = $this->getDefaultAttributes($model);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$mutators[$class])) {
foreach (static::$mutators[$class] as $callback) {
$attributes = array_merge(
$attributes,
$callback($this, $model, $attributes)
);
}
}
}
// Deprecated in beta 15, removed in beta 16
static::$dispatcher->dispatch(
new Serializing($this, $model, $attributes)
);
@@ -102,7 +125,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
* @param DateTime|null $date
* @return string|null
*/
protected function formatDate(DateTime $date = null)
public function formatDate(DateTime $date = null)
{
if ($date) {
return $date->format(DateTime::RFC3339);
@@ -130,10 +153,20 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected function getCustomRelationship($model, $name)
{
// Deprecated in beta 15, removed in beta 16
$relationship = static::$dispatcher->until(
new GetApiRelationship($this, $name, $model)
);
foreach (array_merge([static::class], class_parents($this)) as $class) {
$callback = Arr::get(static::$customRelations, "$class.$name");
if (is_callable($callback)) {
$relationship = $callback($this, $model);
break;
}
}
if ($relationship && ! ($relationship instanceof Relationship)) {
throw new LogicException(
'GetApiRelationship handler must return an instance of '.Relationship::class
@@ -280,4 +313,27 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
{
static::$container = $container;
}
/**
* @param string $serializerClass
* @param callable $mutator
*/
public static function addMutator(string $serializerClass, callable $mutator)
{
if (! isset(static::$mutators[$serializerClass])) {
static::$mutators[$serializerClass] = [];
}
static::$mutators[$serializerClass][] = $mutator;
}
/**
* @param string $serializerClass
* @param string $relation
* @param callable $callback
*/
public static function setRelationship(string $serializerClass, string $relation, callable $callback)
{
static::$customRelations[$serializerClass][$relation] = $callback;
}
}

View File

@@ -21,6 +21,8 @@ use Flarum\Api\Serializer\AbstractSerializer;
* @see AbstractSerializer::hasOne()
* @see AbstractSerializer::hasMany()
* @see https://github.com/tobscure/json-api
*
* @deprecated in beta 15, removed in beta 16
*/
class GetApiRelationship
{

View File

@@ -1,39 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15. Use the User extender instead.
* The `PrepareUserGroups` event.
*/
class PrepareUserGroups
{
/**
* @var User
*/
public $user;
/**
* @var array
*/
public $groupIds;
/**
* @param User $user
* @param array $groupIds
*/
public function __construct(User $user, array &$groupIds)
{
$this->user = $user;
$this->groupIds = &$groupIds;
}
}

View File

@@ -0,0 +1,162 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class ApiSerializer implements ExtenderInterface
{
private $serializerClass;
private $attributes = [];
private $mutators = [];
private $relationships = [];
/**
* @param string $serializerClass The ::class attribute of the serializer you are modifying.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
*/
public function __construct(string $serializerClass)
{
$this->serializerClass = $serializerClass;
}
/**
* @param string $name: The name of the attribute.
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
* - $attributes: An array of existing attributes.
*
* The callable should return:
* - The value of the attribute.
*
* @return self
*/
public function attribute(string $name, $callback)
{
$this->attributes[$name] = $callback;
return $this;
}
/**
* Add to or modify the attributes array of this serializer.
*
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
* - $attributes: An array of existing attributes.
*
* The callable should return:
* - An array of additional attributes to merge with the existing array.
* Or a modified $attributes array.
*
* @return self
*/
public function mutate($callback)
{
$this->mutators[] = $callback;
return $this;
}
/**
* Establish a simple hasOne relationship from this serializer to another serializer.
* This represents a one-to-one relationship.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param string $serializerClass: The ::class attribute the serializer that handles this relation.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @return self
*/
public function hasOne(string $name, string $serializerClass)
{
return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) {
return $serializer->hasOne($model, $serializerClass, $name);
});
}
/**
* Establish a simple hasMany relationship from this serializer to another serializer.
* This represents a one-to-many relationship.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param string $serializerClass: The ::class attribute the serializer that handles this relation.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @return self
*/
public function hasMany(string $name, string $serializerClass)
{
return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) {
return $serializer->hasMany($model, $serializerClass, $name);
});
}
/**
* Add a relationship from this serializer to another serializer.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param callable|string $callback
*
* The callable can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
*
* The callable should return:
* - $relationship: An instance of \Tobscure\JsonApi\Relationship.
*
* @return self
*/
public function relationship(string $name, $callback)
{
$this->relationships[$this->serializerClass][$name] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if (! empty($this->attributes)) {
$this->mutators[] = function ($serializer, $model, $attributes) use ($container) {
foreach ($this->attributes as $attributeName => $callback) {
$callback = ContainerUtil::wrapCallback($callback, $container);
$attributes[$attributeName] = $callback($serializer, $model, $attributes);
}
return $attributes;
};
}
foreach ($this->mutators as $mutator) {
$mutator = ContainerUtil::wrapCallback($mutator, $container);
AbstractSerializer::addMutator($this->serializerClass, $mutator);
}
foreach ($this->relationships as $serializerClass => $relationships) {
foreach ($relationships as $relation => $callback) {
$callback = ContainerUtil::wrapCallback($callback, $container);
AbstractSerializer::setRelationship($serializerClass, $relation, $callback);
}
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class ThrottleApi implements ExtenderInterface
{
private $setThrottlers = [];
private $removeThrottlers = [];
/**
* Add a new throttler (or override one with the same name).
*
* @param string $name: The name of the throttler.
* @param string|callable $callback
*
* The callable can be a closure or invokable class, and should accept:
* - $request: The current `\Psr\Http\Message\ServerRequestInterface` request object.
* `$request->getAttribute('actor')` can be used to get the current user.
* `$request->getAttribute('routeName')` can be used to get the current route.
* Please note that every throttler runs by default on every route.
* If you only want to throttle certain routes, you'll need to check for that inside your logic.
*
* The callable should return one of:
* - `false`: This marks the request as NOT to be throttled. It overrides all other throttlers
* - `true`: This marks the request as to be throttled.
* All other outputs will be ignored.
*
* @return self
*/
public function set(string $name, $callback)
{
$this->setThrottlers[$name] = $callback;
return $this;
}
/**
* Remove a throttler registered with this name.
*
* @param string $name: The name of the throttler to remove.
*
* @return self
*/
public function remove(string $name)
{
$this->removeThrottlers[] = $name;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.api.throttlers', function ($throttlers) use ($container) {
$throttlers = array_diff_key($throttlers, array_flip($this->removeThrottlers));
foreach ($this->setThrottlers as $name => $throttler) {
$throttlers[$name] = ContainerUtil::wrapCallback($throttler, $container);
}
return $throttlers;
});
}
}

View File

@@ -21,7 +21,7 @@ class Application
*
* @var string
*/
const VERSION = '0.1.0-beta.14';
const VERSION = '0.1.0-beta.14.1';
/**
* The IoC container for the Flarum application.
@@ -153,50 +153,6 @@ class Application
$this->register(new EventServiceProvider($this->container));
}
/**
* Get the base path of the Laravel installation.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function basePath()
{
return $this->paths->base;
}
/**
* Get the path to the public / web directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function publicPath()
{
return $this->paths->public;
}
/**
* Get the path to the storage directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function storagePath()
{
return $this->paths->storage;
}
/**
* Get the path to the vendor directory where dependencies are installed.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function vendorPath()
{
return $this->paths->vendor;
}
/**
* Register a service provider with the application.
*

View File

@@ -38,7 +38,7 @@ class AuthenticateWithHeader implements Middleware
$actor = $key->user ?? $this->getUser($userId);
$request = $request->withAttribute('apiKey', $key);
$request = $request->withAttribute('bypassFloodgate', true);
$request = $request->withAttribute('bypassThrottling', true);
} elseif ($token = AccessToken::find($id)) {
$token->touch();

View File

@@ -25,7 +25,5 @@ interface MailableInterface
*
* @return string
*/
// TODO: This is temporarily commented out to avoid BC breaks between beta 13 and beta 14.
// It should be uncommented before beta 15.
// public function getEmailSubject(TranslatorInterface $translator);
public function getEmailSubject(TranslatorInterface $translator);
}

View File

@@ -15,6 +15,9 @@ use Flarum\Post\Exception\FloodingException;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
/**
* @deprecated beta 14, removed beta 15 in favor of Floodgate middleware
*/
class Floodgate
{
/**

View File

@@ -9,11 +9,38 @@
namespace Flarum\Post;
use DateTime;
use Flarum\Event\ConfigurePostTypes;
use Flarum\Foundation\AbstractServiceProvider;
class PostServiceProvider extends AbstractServiceProvider
{
/**
* {@inheritdoc}
*/
public function register()
{
$this->app->extend('flarum.api.throttlers', function ($throttlers) {
$throttlers['postTimeout'] = function ($request) {
if (! in_array($request->getAttribute('routeName'), ['discussions.create', 'posts.create'])) {
return;
}
$actor = $request->getAttribute('actor');
if ($actor->can('postWithoutThrottle')) {
return false;
}
if (Post::where('user_id', $actor->id)->where('created_at', '>=', new DateTime('-10 seconds'))->exists()) {
return true;
}
};
return $throttlers;
});
}
/**
* {@inheritdoc}
*/

View File

@@ -1,72 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\Exception\PermissionDeniedException;
/**
* @deprecated beta 14, remove beta 15. Please use direct methods of the User class instead. E.g. $actor->assertCan($ability);
*/
trait AssertPermissionTrait
{
/**
* Ensure the current user is allowed to do something.
*
* If the condition is not met, an exception will be thrown that signals the
* lack of permissions. This is about *authorization*, i.e. retrying such a
* request / operation without a change in permissions (or using another
* user account) is pointless.
*
* @param bool $condition
* @throws PermissionDeniedException
*/
protected function assertPermission($condition)
{
if (! $condition) {
throw new PermissionDeniedException;
}
}
/**
* Ensure the given actor is authenticated.
*
* This will throw an exception for guest users, signaling that
* *authorization* failed. Thus, they could retry the operation after
* logging in (or using other means of authentication).
*
* @param User $actor
* @throws NotAuthenticatedException
*/
protected function assertRegistered(User $actor)
{
$actor->assertRegistered();
}
/**
* @param User $actor
* @param string $ability
* @param mixed $arguments
* @throws PermissionDeniedException
*/
protected function assertCan(User $actor, $ability, $arguments = [])
{
$actor->assertCan($ability, $arguments);
}
/**
* @param User $actor
* @throws PermissionDeniedException
*/
protected function assertAdmin(User $actor)
{
$actor->assertCan('administrate');
}
}

View File

@@ -1,31 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15.
*/
class GetDisplayName
{
/**
* @var User
*/
public $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@@ -15,7 +15,6 @@ use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Discussion\Discussion;
use Flarum\Event\ConfigureUserPreferences;
use Flarum\Event\PrepareUserGroups;
use Flarum\Foundation\EventGeneratorTrait;
use Flarum\Group\Group;
use Flarum\Group\Permission;
@@ -30,7 +29,6 @@ use Flarum\User\Event\CheckingPassword;
use Flarum\User\Event\Deleted;
use Flarum\User\Event\EmailChanged;
use Flarum\User\Event\EmailChangeRequested;
use Flarum\User\Event\GetDisplayName;
use Flarum\User\Event\PasswordChanged;
use Flarum\User\Event\Registered;
use Flarum\User\Event\Renamed;
@@ -327,8 +325,7 @@ class User extends AbstractModel
*/
public function getDisplayNameAttribute()
{
// Event is deprecated in beta 14, remove in beta 15.
return static::$dispatcher->until(new GetDisplayName($this)) ?: static::$displayNameDriver->displayName($this);
return static::$displayNameDriver->displayName($this);
}
/**
@@ -722,9 +719,6 @@ class User extends AbstractModel
$groupIds = array_merge($groupIds, [Group::MEMBER_ID], $this->groups->pluck('id')->all());
}
/** @deprecated in beta 14, remove in beta 15 */
event(new PrepareUserGroups($this, $groupIds));
foreach (static::$groupProcessors as $processor) {
$groupIds = $processor($this, $groupIds);
}

View File

@@ -14,7 +14,6 @@ use Flarum\Discussion\Event\Deleted as DiscussionDeleted;
use Flarum\Discussion\Event\Started;
use Flarum\Post\Event\Deleted as PostDeleted;
use Flarum\Post\Event\Posted;
use Flarum\Post\Post;
use Illuminate\Contracts\Events\Dispatcher;
class UserMetadataUpdater
@@ -35,7 +34,7 @@ class UserMetadataUpdater
*/
public function whenPostWasPosted(Posted $event)
{
$this->updateCommentsCount($event->post);
$this->updateCommentsCount($event->post->user);
}
/**
@@ -43,7 +42,7 @@ class UserMetadataUpdater
*/
public function whenPostWasDeleted(PostDeleted $event)
{
$this->updateCommentsCount($event->post);
$this->updateCommentsCount($event->post->user);
}
/**
@@ -60,12 +59,14 @@ class UserMetadataUpdater
public function whenDiscussionWasDeleted(DiscussionDeleted $event)
{
$this->updateDiscussionsCount($event->discussion);
$this->updateCommentsCount($event->discussion->user);
}
private function updateCommentsCount(Post $post)
/**
* @param \Flarum\User\User $user
*/
private function updateCommentsCount(User $user)
{
$user = $post->user;
if ($user && $user->exists) {
$user->refreshCommentCount()->save();
}

View File

@@ -7,7 +7,6 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Foundation\Paths;
use Illuminate\Container\Container;
if (! function_exists('app')) {
@@ -28,48 +27,6 @@ if (! function_exists('app')) {
}
}
if (! function_exists('base_path')) {
/**
* Get the path to the base of the install.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function base_path($path = '')
{
return app(Paths::class)->base.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('public_path')) {
/**
* Get the path to the public folder.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function public_path($path = '')
{
return app(Paths::class)->public.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('storage_path')) {
/**
* Get the path to the storage folder.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function storage_path($path = '')
{
return app(Paths::class)->storage.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('event')) {
/**
* Fire an event and call the listeners.

View File

@@ -28,6 +28,9 @@ class WithApiKeyTest extends TestCase
$this->adminUser(),
$this->normalUser(),
],
'group_permission' => [
['permission' => 'viewUserList', 'group_id' => 3]
],
'api_keys' => [],
]);
}

View File

@@ -27,13 +27,20 @@ class CreateTest extends TestCase
'posts' => [],
'users' => [
$this->adminUser(),
$this->normalUser(),
],
'groups' => [
$this->adminGroup(),
$this->memberGroup(),
],
'group_user' => [
['user_id' => 1, 'group_id' => 1],
['user_id' => 2, 'group_id' => 3],
],
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 3],
['permission' => 'startDiscussion', 'group_id' => 3],
]
]);
}
@@ -137,4 +144,76 @@ class CreateTest extends TestCase
$this->assertEquals('test - too-obscure', $discussion->title);
$this->assertEquals('test - too-obscure', Arr::get($data, 'data.attributes.title'));
}
/**
* @test
*/
public function discussion_creation_limited_by_throttler()
{
$this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'predetermined content for automated testing - too-obscure',
],
]
],
])
);
$response = $this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'Second predetermined content for automated testing - too-obscure',
],
]
],
])
);
$this->assertEquals(429, $response->getStatusCode());
}
/**
* @test
*/
public function throttler_doesnt_apply_to_admin()
{
$this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'predetermined content for automated testing - too-obscure',
],
]
],
])
);
$response = $this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'Second predetermined content for automated testing - too-obscure',
],
]
],
])
);
$this->assertEquals(201, $response->getStatusCode());
}
}

View File

@@ -64,4 +64,44 @@ class CreateTest extends TestCase
$this->assertEquals(201, $response->getStatusCode());
}
/**
* @test
*/
public function limited_by_throttler()
{
$this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'content' => 'reply with predetermined content for automated testing - too-obscure',
],
'relationships' => [
'discussion' => ['data' => ['id' => 1]],
],
],
],
])
);
$response = $this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'content' => 'Second reply with predetermined content for automated testing - too-obscure',
],
'relationships' => [
'discussion' => ['data' => ['id' => 1]],
],
],
],
])
);
$this->assertEquals(429, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,523 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\Post;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
class ApiSerializerTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser()
],
'discussions' => [
['id' => 1, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
['id' => 2, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
['id' => 3, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'discussionRenamed', 'content' => '<t><p>can i haz relationz?</p></t>'],
],
]);
}
protected function prepSettingsDb()
{
$this->prepareDatabase([
'settings' => [
['key' => 'customPrefix.customSetting', 'value' => 'customValue']
],
]);
}
/**
* @test
*/
public function custom_attributes_dont_exist_by_default()
{
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('customAttribute', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_exist_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->mutate(function () {
return [
'customAttribute' => true
];
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttribute', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_with_invokable_exist_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->mutate(CustomAttributesInvokableClass::class)
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttributeFromInvokable', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_exist_if_added_to_parent_class()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->mutate(function () {
return [
'customAttribute' => true
];
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttribute', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_prioritize_child_classes()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->mutate(function () {
return [
'customAttribute' => 'initialValue'
];
}),
(new Extend\ApiSerializer(UserSerializer::class))
->mutate(function () {
return [
'customAttribute' => 'newValue'
];
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttribute', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['customAttribute']);
}
/**
* @test
*/
public function custom_single_attribute_exists_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('customSingleAttribute', function () {
return true;
})->attribute('customSingleAttribute_0', function () {
return 0;
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute', $payload['data']['attributes']);
$this->assertArrayHasKey('customSingleAttribute_0', $payload['data']['attributes']);
$this->assertEquals(0, $payload['data']['attributes']['customSingleAttribute_0']);
}
/**
* @test
*/
public function custom_single_attribute_with_invokable_exists_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('customSingleAttribute_1', CustomSingleAttributeInvokableClass::class)
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute_1', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_single_attribute_exists_if_added_to_parent_class()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->attribute('customSingleAttribute_2', function () {
return true;
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute_2', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_single_attribute_prioritizes_child_classes()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->attribute('customSingleAttribute_3', function () {
return 'initialValue';
}),
(new Extend\ApiSerializer(UserSerializer::class))
->attribute('customSingleAttribute_3', function () {
return 'newValue';
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute_3', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['customSingleAttribute_3']);
}
/**
* @test
*/
public function custom_attributes_can_be_overriden()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->attribute('someCustomAttribute', function () {
return 'newValue';
})->mutate(function () {
return [
'someCustomAttribute' => 'initialValue',
'someOtherCustomAttribute' => 'initialValue',
];
})->attribute('someOtherCustomAttribute', function () {
return 'newValue';
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('someCustomAttribute', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['someCustomAttribute']);
$this->assertArrayHasKey('someOtherCustomAttribute', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['someOtherCustomAttribute']);
}
/**
* @test
*/
public function custom_hasMany_relationship_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasMany('customSerializerRelation', DiscussionSerializer::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertCount(3, $relationship->toArray()['data']);
}
/**
* @test
*/
public function custom_hasOne_relationship_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasOne('customSerializerRelation', DiscussionSerializer::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
/**
* @test
*/
public function custom_relationship_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->relationship('customSerializerRelation', function (AbstractSerializer $serializer, $model) {
return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation');
})
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
/**
* @test
*/
public function custom_relationship_with_invokable_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->relationship('customSerializerRelation', CustomRelationshipInvokableClass::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
/**
* @test
*/
public function custom_relationship_is_inherited_to_child_classes()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('anotherCustomRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(BasicUserSerializer::class))
->hasMany('anotherCustomRelation', DiscussionSerializer::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'anotherCustomRelation');
$this->assertNotEmpty($relationship);
$this->assertCount(3, $relationship->toArray()['data']);
}
/**
* @test
*/
public function custom_relationship_prioritizes_child_classes()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('postCustomRelation', Post::class, 'user_id'),
(new Extend\Model(User::class))
->hasOne('discussionCustomRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(BasicUserSerializer::class))
->hasOne('postCustomRelation', PostSerializer::class),
(new Extend\ApiSerializer(UserSerializer::class))
->relationship('postCustomRelation', function (AbstractSerializer $serializer, $model) {
return $serializer->hasOne($model, DiscussionSerializer::class, 'discussionCustomRelation');
})
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'postCustomRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
}
class CustomAttributesInvokableClass
{
public function __invoke()
{
return [
'customAttributeFromInvokable' => true
];
}
}
class CustomSingleAttributeInvokableClass
{
public function __invoke()
{
return true;
}
}
class CustomRelationshipInvokableClass
{
public function __invoke(AbstractSerializer $serializer, $model)
{
return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation');
}
}

View File

@@ -29,6 +29,12 @@ class MailTest extends TestCase
'users' => [
$this->adminUser(),
],
'groups' => [
$this->adminGroup(),
],
'group_user' => [
['user_id' => 1, 'group_id' => 1],
],
]);
}

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Flarum\Extend;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
class ThrottleApiTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb(): void
{
$this->prepareDatabase([
'users' => [
$this->normalUser(),
],
'groups' => [
$this->memberGroup(),
],
'group_user' => [
['user_id' => 2, 'group_id' => 3],
],
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 3],
]
]);
}
/**
* @test
*/
public function list_discussions_not_restricted_by_default()
{
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function list_discussions_can_be_restricted()
{
$this->extend((new Extend\ThrottleApi)->set('blockListDiscussions', function ($request) {
if ($request->getAttribute('routeName') === 'discussions.index') {
return true;
}
}));
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(429, $response->getStatusCode());
}
/**
* @test
*/
public function false_overrides_true_for_evaluating_throttlers()
{
$this->extend(
(new Extend\ThrottleApi)->set('blockListDiscussions', function ($request) {
if ($request->getAttribute('routeName') === 'discussions.index') {
return true;
}
}),
(new Extend\ThrottleApi)->set('blockListDiscussionsOverride', function ($request) {
if ($request->getAttribute('routeName') === 'discussions.index') {
return false;
}
})
);
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(200, $response->getStatusCode());
}
}

View File

@@ -26,6 +26,9 @@ class UserTest extends TestCase
$this->adminUser(),
$this->normalUser(),
],
'group_permission' => [
['permission' => 'viewUserList', 'group_id' => 3]
],
'settings' => [
['key' => 'display_name_driver', 'value' => 'custom'],
],

View File

@@ -6,7 +6,7 @@
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
processIsolation="true"
stopOnFailure="false">
<testsuites>