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

Compare commits

...

47 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
flarum-bot
9cb9097b24 Bundled output for commit 571a835be0 [skip ci] 2020-11-14 22:23:04 +00:00
Wadim Kalmykov
571a835be0 Fix mobile PostStream top scroll adjustment & remove App:before (#2385)
- remove App:before so we can use #app-navigation to access the mobile header
- fix mobile postStream scroll top margin adjustment
2020-11-14 17:21:38 -05:00
Alexander Skvortsov
0c95774333 Refactor Route Resolving and Dispatch (#2425)
- Split DispatchRoute. This allows us to run middleware after we figure out which route we're on, but before we actually execute the controller for that route.
- By making the route name explicitly available to middlewares, applications like CSRF and floodgate can set patterns based on route names instead of the path, which is an implementation detail.
- Support using route name match for CSRF extender, deprecate path match
2020-11-10 12:52:12 -05:00
Nina Pypchenko
67741c7a6f Make checkbox switch component background stand out in modals (#2443) 2020-11-09 20:54:21 -05:00
Alexander Skvortsov
f5cfec15e3 Add missing import 2020-11-08 21:49:11 -05:00
Alexander Skvortsov
47d2eee9ce Fix Callables for Extenders (#2423)
- Standardize signatures and variable names for extenders that take callbacks
- Adjust model extender docblock to clarify that default calue can't be an invokable class.
- Make invokable classes provided to Model->relationship
- Add integration tests to ensure Model->relationship and User->groupProcessor extenders accept callbacks
- Extract code for wrapping callbacks into central util
2020-11-08 21:36:38 -05:00
Nina Pypchenko
c10cc92deb Improved Permissions Error Messages for Initial Install (#2435)
- Made the wording of the error more generic
- Added link to the relevant section in the installation guide

Resolves #2327.
2020-11-07 14:48:11 -05:00
Sami Mazouz
529d2edcaf Add Service Provider Extender (#2437) 2020-11-06 13:30:10 -05:00
Sami Mazouz
f0e77a5789 Add Notification Channel Extender (#2432) 2020-11-05 12:09:06 -05:00
Alexander Skvortsov
87c258b2f8 Refactor and improve formatter extender (#2098)
- Deprecated all events involved with Formatter
- Refactor ->configure() method on extender not to use events
- Add extender methods for ->render() and ->parse()
- Add integration tests
2020-11-03 13:05:33 -05:00
Alexander Skvortsov
cee87848fe Added post extender with type method, deprecated ConfigurePostTypes (#2101) 2020-11-03 10:43:49 -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
5842dd1200 Validator extender (#2102)
Added validator extender, integration tests, and deprecated related Validating event
2020-11-01 11:31:16 -05:00
Sami Mazouz
b311512502 Add Notification Type Extender and Tests (#2424) 2020-10-31 17:17:14 -04:00
Alexander Skvortsov
9b9f2c4bb7 Fix exiting composer while in fullscreen mode. 2020-10-30 20:44:52 -04:00
flarum-bot
0b2a5fa5b8 Bundled output for commit 52e45aacad [skip ci] 2020-10-31 00:28:56 +00:00
Lucas Henrique
52e45aacad Convert common time helpers to Typescript (#2391) 2020-10-30 20:27:40 -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
Alexander Skvortsov
21c2a4b2a4 Updater should show on any subpath, like installer (#2426) 2020-10-30 13:28:20 -04:00
flarum-bot
12c03dc4e1 Bundled output for commit d2927cfdb9 [skip ci] 2020-10-29 16:54:36 +00:00
Alexander Skvortsov
d2927cfdb9 Ensure scripts provided by textformatter are run (#2415) 2020-10-29 12:53:23 -04:00
Daniël Klabbers
24b7a21507 Update Symfony components to v4 (#2407)
This matches the Symfony dependencies of our laravel dependencies.
2020-10-27 17:12:36 -04:00
flarum-bot
c9a04fe009 Bundled output for commit bd7fa11b5a [skip ci] 2020-10-25 17:36:51 +00:00
Alexander Skvortsov
bd7fa11b5a Export SuperTextarea util in compat 2020-10-25 13:35:15 -04:00
139 changed files with 4165 additions and 1041 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

View File

@@ -76,11 +76,11 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6",
"symfony/config": "^3.3",
"symfony/console": "^4.2",
"symfony/event-dispatcher": "^4.3.2",
"symfony/translation": "^3.3",
"symfony/yaml": "^3.3",
"symfony/config": "^4.3.4",
"symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.4",
"symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
},

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

@@ -19,6 +19,7 @@ import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril';
import classList from './utils/classList';
import extractText from './utils/extractText';
@@ -90,6 +91,7 @@ export default {
'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/classList': classList,

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,11 +1,11 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
/**
* The `fullTime` helper displays a formatted time string wrapped in a <time>
* tag.
*
* @param {Date} time
* @return {Object}
*/
export default function fullTime(time) {
export default function fullTime(time: Date): Mithril.Vnode {
const d = dayjs(time);
const datetime = d.format();

View File

@@ -1,14 +1,13 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
import humanTimeUtil from '../utils/humanTime';
/**
* The `humanTime` helper displays a time in a human-friendly time-ago format
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
* the time.
*
* @param {Date} time
* @return {Object}
*/
export default function humanTime(time) {
export default function humanTime(time: Date): Mithril.Vnode {
const d = dayjs(time);
const datetime = d.format();

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,3 +1,6 @@
import dayjs from 'dayjs';
import 'dayjs/plugin/relativeTime';
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.

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

@@ -56,9 +56,7 @@ export default class CommentPost extends Post {
]);
}
onupdate(vnode) {
super.onupdate();
refreshContent() {
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through
@@ -66,13 +64,28 @@ export default class CommentPost extends Post {
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () {
eval.call(window, $(this).text());
const script = document.createElement('script');
script.textContent = this.textContent;
Array.from(this.attributes).forEach((attr) => script.setAttribute(attr.name, attr.value));
this.parentNode.replaceChild(script, this);
});
}
this.contentHtml = contentHtml;
}
oncreate(vnode) {
super.oncreate(vnode);
this.refreshContent();
}
onupdate(vnode) {
super.onupdate(vnode);
this.refreshContent();
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
}

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

@@ -287,7 +287,9 @@ export default class PostStream extends Component {
* @return {Integer}
*/
getMarginTop() {
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
const headerId = app.screen() === 'phone' ? '#app-navigation' : '#header';
return this.$() && $(headerId).outerHeight() + parseInt(this.$().css('margin-top'), 10);
}
/**

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

@@ -10,29 +10,23 @@
}
}
// Fix a solid white box to the top of the viewport. This toolbar's contents
// will differ depending on the device: on phones it will be content
// controls, whereas on desktops it will be the header. We will overlay
// these things on top of it later.
.App:before {
content: " ";
.header-background();
border-bottom: 0;
position: absolute;
.affix& {
position: fixed;
}
.scrolled& {
.box-shadow(0 2px 6px @shadow-color);
}
}
// PHONES: Somewhere on the page there will be a .App-backControl, a
// .App-primaryControl, and a .App-titleControl. We will position these on the
// left, right, and center of the header respectively.
@media @phone {
.App-navigation {
.header-background();
border-bottom: 0;
position: absolute;
.affix & {
position: fixed;
}
.scrolled & {
.box-shadow(0 2px 6px @shadow-color);
}
}
.App-primaryControl, .App-titleControl, .App-backControl {
position: absolute !important;
z-index: @zindex-header + 1;
@@ -234,18 +228,18 @@
display: none;
}
.App-header {
.header-background();
padding: 8px;
height: @header-height;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: @zindex-header;
.affix & {
position: fixed;
}
.scrolled & {
.box-shadow(0 2px 6px @shadow-color);
}
& when (@config-colored-header = true) {
.light-contents(@header-color, @header-control-bg, @header-control-color);
}

View File

@@ -105,6 +105,10 @@
text-align: left;
}
}
.off.Checkbox--switch .Checkbox-display {
background: @muted-more-color;
}
}
.Modal-footer {
border: 0;

View File

@@ -54,9 +54,10 @@ class AdminServiceProvider extends AbstractServiceProvider
HttpMiddleware\StartSession::class,
HttpMiddleware\RememberFromCookie::class,
HttpMiddleware\AuthenticateWithSession::class,
HttpMiddleware\CheckCsrfToken::class,
HttpMiddleware\SetLocale::class,
Middleware\RequireAdministrateAbility::class,
'flarum.admin.route_resolver',
HttpMiddleware\CheckCsrfToken::class,
Middleware\RequireAdministrateAbility::class
];
});
@@ -68,6 +69,10 @@ class AdminServiceProvider extends AbstractServiceProvider
);
});
$this->app->bind('flarum.admin.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.admin.routes'));
});
$this->app->singleton('flarum.admin.handler', function () {
$pipe = new MiddlewarePipe;
@@ -75,7 +80,7 @@ class AdminServiceProvider extends AbstractServiceProvider
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.admin.routes')));
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
return $pipe;
});

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',
@@ -51,8 +65,10 @@ class ApiServiceProvider extends AbstractServiceProvider
HttpMiddleware\RememberFromCookie::class,
HttpMiddleware\AuthenticateWithSession::class,
HttpMiddleware\AuthenticateWithHeader::class,
HttpMiddleware\CheckCsrfToken::class,
HttpMiddleware\SetLocale::class,
'flarum.api.route_resolver',
HttpMiddleware\CheckCsrfToken::class,
Middleware\ThrottleApi::class
];
});
@@ -64,6 +80,10 @@ class ApiServiceProvider extends AbstractServiceProvider
);
});
$this->app->bind('flarum.api.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.api.routes'));
});
$this->app->singleton('flarum.api.handler', function () {
$pipe = new MiddlewarePipe;
@@ -71,10 +91,16 @@ class ApiServiceProvider extends AbstractServiceProvider
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.api.routes')));
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
return $pipe;
});
$this->app->singleton('flarum.api.notification_serializers', function () {
return [
'discussionRenamed' => BasicDiscussionSerializer::class
];
});
}
/**
@@ -82,7 +108,7 @@ class ApiServiceProvider extends AbstractServiceProvider
*/
public function boot()
{
$this->registerNotificationSerializers();
$this->setNotificationSerializers();
AbstractSerializeController::setContainer($this->app);
AbstractSerializeController::setEventDispatcher($events = $this->app->make('events'));
@@ -94,13 +120,12 @@ class ApiServiceProvider extends AbstractServiceProvider
/**
* Register notification serializers.
*/
protected function registerNotificationSerializers()
protected function setNotificationSerializers()
{
$blueprints = [];
$serializers = [
'discussionRenamed' => BasicDiscussionSerializer::class
];
$serializers = $this->app->make('flarum.api.notification_serializers');
// Deprecated in beta 15, remove in beta 16
$this->app->make('events')->dispatch(
new ConfigureNotificationTypes($blueprints, $serializers)
);

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

@@ -13,6 +13,9 @@ use Flarum\Notification\Blueprint\BlueprintInterface;
use InvalidArgumentException;
use ReflectionClass;
/**
* @deprecated in beta 15, removed in beta 16
*/
class ConfigureNotificationTypes
{
/**

View File

@@ -9,6 +9,9 @@
namespace Flarum\Event;
/**
* @deprecated in beta 15, remove in beta 16. Use the Post extender instead.
*/
class ConfigurePostTypes
{
private $models;

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

@@ -14,11 +14,28 @@ use Illuminate\Contracts\Container\Container;
class Csrf implements ExtenderInterface
{
protected $csrfExemptPaths = [];
protected $csrfExemptRoutes = [];
/**
* Exempt a named route from CSRF checks.
*
* @param string $routeName
*/
public function exemptRoute(string $routeName)
{
$this->csrfExemptRoutes[] = $routeName;
return $this;
}
/**
* Exempt a path from csrf checks. Wildcards are supported.
*
* @deprecated beta 15, remove beta 16. Exempt routes should be used instead.
*/
public function exemptPath(string $path)
{
$this->csrfExemptPaths[] = $path;
$this->csrfExemptRoutes[] = $path;
return $this;
}
@@ -26,7 +43,7 @@ class Csrf implements ExtenderInterface
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.http.csrfExemptPaths', function ($existingExemptPaths) {
return array_merge($existingExemptPaths, $this->csrfExemptPaths);
return array_merge($existingExemptPaths, $this->csrfExemptRoutes);
});
}
}

View File

@@ -25,7 +25,7 @@ class Event implements ExtenderInterface
* - the class attribute of a class with a public `handle` method, which accepts an instance of the event as a parameter
*
* @param string $event
* @param callable $listener
* @param callable|string $listener
*/
public function listen(string $event, $listener)
{

View File

@@ -10,38 +10,93 @@
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\Formatter\Event\Configuring;
use Flarum\Formatter\Formatter as ActualFormatter;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
use Illuminate\Events\Dispatcher;
class Formatter implements ExtenderInterface, LifecycleInterface
{
private $callback;
private $configurationCallbacks = [];
private $parsingCallbacks = [];
private $renderingCallbacks = [];
/**
* Configure the formatter. This can be used to add support for custom markdown/bbcode/etc tags,
* or otherwise change the formatter. Please see documentation for the s9e text formatter library for more
* information on how to use this.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \s9e\TextFormatter\Configurator $configurator
*/
public function configure($callback)
{
$this->callback = $callback;
$this->configurationCallbacks[] = $callback;
return $this;
}
/**
* Prepare the system for parsing. This can be used to modify the text that will be parsed, or to modify the parser.
* Please note that the text to be parsed must be returned, regardless of whether it's changed.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \s9e\TextFormatter\Parser $parser
* - mixed $context
* - string $text: The text to be parsed.
*
* The callback should return:
* - string $text: The text to be parsed.
*/
public function parse($callback)
{
$this->parsingCallbacks[] = $callback;
return $this;
}
/**
* Prepare the system for rendering. This can be used to modify the xml that will be rendered, or to modify the renderer.
* Please note that the xml to be rendered must be returned, regardless of whether it's changed.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \s9e\TextFormatter\Rendered $renderer
* - mixed $context
* - string $xml: The xml to be rendered.
* - ServerRequestInterface $request
*
* The callback should return:
* - string $xml: The xml to be rendered.
*/
public function render($callback)
{
$this->renderingCallbacks[] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$events = $container->make(Dispatcher::class);
$events->listen(
Configuring::class,
function (Configuring $event) use ($container) {
if (is_string($this->callback)) {
$callback = $container->make($this->callback);
} else {
$callback = $this->callback;
}
$callback($event->configurator);
$container->extend('flarum.formatter', function ($formatter, $container) {
foreach ($this->configurationCallbacks as $callback) {
$formatter->addConfigurationCallback(ContainerUtil::wrapCallback($callback, $container));
}
);
foreach ($this->parsingCallbacks as $callback) {
$formatter->addParsingCallback(ContainerUtil::wrapCallback($callback, $container));
}
foreach ($this->renderingCallbacks as $callback) {
$formatter->addRenderingCallback(ContainerUtil::wrapCallback($callback, $container));
}
return $formatter;
});
}
public function onEnable(Container $container, Extension $extension)

View File

@@ -12,6 +12,7 @@ namespace Flarum\Extend;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Frontend\Assets;
use Flarum\Frontend\Compiler\Source\SourceCollector;
@@ -171,11 +172,7 @@ class Frontend implements ExtenderInterface
"flarum.frontend.$this->frontend",
function (ActualFrontend $frontend, Container $container) {
foreach ($this->content as $content) {
if (is_string($content)) {
$content = $container->make($content);
}
$frontend->content($content);
$frontend->content(ContainerUtil::wrapCallback($content, $container));
}
}
);

View File

@@ -11,12 +11,14 @@ namespace Flarum\Extend;
use Flarum\Database\AbstractModel;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
class Model implements ExtenderInterface
{
private $modelClass;
private $customRelations = [];
/**
* @param string $modelClass The ::class attribute of the model you are modifying.
@@ -48,7 +50,9 @@ class Model implements ExtenderInterface
}
/**
* Add a default value for a given attribute, which can be an explicit value, or a closure.
* Add a default value for a given attribute, which can be an explicit value, a closure,
* or an instance of an invokable class. Unlike with some other extenders,
* it CANNOT be the `::class` attribute of an invokable class.
*
* @param string $attribute
* @param mixed $value
@@ -157,7 +161,7 @@ class Model implements ExtenderInterface
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param callable $callable
* @param callable|string $callback
*
* The callable can be a closure or invokable class, and should accept:
* - $instance: An instance of this model.
@@ -168,15 +172,17 @@ class Model implements ExtenderInterface
*
* @return self
*/
public function relationship(string $name, callable $callable)
public function relationship(string $name, $callback)
{
Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", $callable);
$this->customRelations[$name] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
// Nothing needed here.
foreach ($this->customRelations as $name => $callback) {
Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", ContainerUtil::wrapCallback($callback, $container));
}
}
}

View File

@@ -0,0 +1,78 @@
<?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 Illuminate\Contracts\Container\Container;
class Notification implements ExtenderInterface
{
private $blueprints = [];
private $serializers = [];
private $drivers = [];
private $typesEnabledByDefault = [];
/**
* @param string $blueprint The ::class attribute of the blueprint class.
* This blueprint should implement \Flarum\Notification\Blueprint\BlueprintInterface.
* @param string $serializer The ::class attribute of the serializer class.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @param string[] $driversEnabledByDefault The names of the drivers enabled by default for this notification type.
* (example: alert, email).
* @return self
*/
public function type(string $blueprint, string $serializer, array $driversEnabledByDefault = [])
{
$this->blueprints[$blueprint] = $driversEnabledByDefault;
$this->serializers[$blueprint::getType()] = $serializer;
return $this;
}
/**
* @param string $driverName The name of the notification driver.
* @param string $driver The ::class attribute of the driver class.
* This driver should implement \Flarum\Notification\Driver\NotificationDriverInterface.
* @param string[] $typesEnabledByDefault The names of blueprint classes of types enabled by default for this driver.
* @return self
*/
public function driver(string $driverName, string $driver, array $typesEnabledByDefault = [])
{
$this->drivers[$driverName] = $driver;
$this->typesEnabledByDefault[$driverName] = $typesEnabledByDefault;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.notification.blueprints', function ($existingBlueprints) {
$existingBlueprints = array_merge($existingBlueprints, $this->blueprints);
foreach ($this->typesEnabledByDefault as $driverName => $typesEnabledByDefault) {
foreach ($typesEnabledByDefault as $blueprintClass) {
if (isset($existingBlueprints[$blueprintClass]) && (! in_array($driverName, $existingBlueprints[$blueprintClass]))) {
$existingBlueprints[$blueprintClass][] = $driverName;
}
}
}
return $existingBlueprints;
});
$container->extend('flarum.api.notification_serializers', function ($existingSerializers) {
return array_merge($existingSerializers, $this->serializers);
});
$container->extend('flarum.notification.drivers', function ($existingDrivers) {
return array_merge($existingDrivers, $this->drivers);
});
}
}

39
src/Extend/Post.php Normal file
View File

@@ -0,0 +1,39 @@
<?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\Post\Post as PostModel;
use Illuminate\Contracts\Container\Container;
class Post implements ExtenderInterface
{
private $postTypes = [];
/**
* Register a new post type. This is generally done for custom 'event posts',
* such as those that appear when a discussion is renamed.
*
* @param string $postType: The ::class attribute of the custom Post type that is being added.
*/
public function type(string $postType)
{
$this->postTypes[] = $postType;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
foreach ($this->postTypes as $postType) {
PostModel::setModel($postType::$type, $postType);
}
}
}

View File

@@ -0,0 +1,40 @@
<?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 Illuminate\Contracts\Container\Container;
class ServiceProvider implements ExtenderInterface
{
private $providers = [];
/**
* Register a service provider.
*
* @param string $serviceProviderClass The ::class attribute of the service provider class.
* @return self
*/
public function register(string $serviceProviderClass)
{
$this->providers[] = $serviceProviderClass;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$app = $container->make('flarum');
foreach ($this->providers as $provider) {
$app->register($provider);
}
}
}

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

@@ -35,7 +35,7 @@ class User implements ExtenderInterface
* This can be used to give a user permissions for groups they aren't actually in, based on context.
* It will not change the group badges displayed for the user.
*
* @param callable $callable
* @param callable|string $callback
*
* The callable can be a closure or invokable class, and should accept:
* - \Flarum\User\User $user: the user in question.
@@ -44,9 +44,9 @@ class User implements ExtenderInterface
* The callable should return:
* - array $groupIds: an array of ids for the groups the user belongs to.
*/
public function permissionGroups(callable $callable)
public function permissionGroups($callback)
{
$this->groupProcessors[] = $callable;
$this->groupProcessors[] = $callback;
return $this;
}

55
src/Extend/Validator.php Normal file
View File

@@ -0,0 +1,55 @@
<?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 Validator implements ExtenderInterface
{
private $configurationCallbacks = [];
private $validator;
/**
* @param string $validatorClass: The ::class attribute of the validator you are modifying.
* The validator should inherit from \Flarum\Foundation\AbstractValidator.
*/
public function __construct($validatorClass)
{
$this->validator = $validatorClass;
}
/**
* Configure the validator. This is often used to adjust validation rules, but can be
* used to make other changes to the validator as well.
*
* @param callable $callable
*
* The callable can be a closure or invokable class, and should accept:
* - \Flarum\Foundation\AbstractValidator $flarumValidator: The Flarum validator wrapper
* - \Illuminate\Validation\Validator $validator: The Laravel validator instance
*/
public function configure($callback)
{
$this->configurationCallbacks[] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->resolving($this->validator, function ($validator, $container) {
foreach ($this->configurationCallbacks as $callback) {
$validator->addConfiguration(ContainerUtil::wrapCallback($callback, $container));
}
});
}
}

View File

@@ -11,6 +11,9 @@ namespace Flarum\Formatter\Event;
use s9e\TextFormatter\Configurator;
/**
* @deprecated beta 15, removed beta 16. Use the Formatter extender instead.
*/
class Configuring
{
/**

View File

@@ -11,6 +11,9 @@ namespace Flarum\Formatter\Event;
use s9e\TextFormatter\Parser;
/**
* @deprecated beta 15, removed beta 16. Use the Formatter extender instead.
*/
class Parsing
{
/**

View File

@@ -12,6 +12,9 @@ namespace Flarum\Formatter\Event;
use Psr\Http\Message\ServerRequestInterface;
use s9e\TextFormatter\Renderer;
/**
* @deprecated beta 15, removed beta 16. Use the Formatter extender instead.
*/
class Rendering
{
/**

View File

@@ -20,6 +20,12 @@ use s9e\TextFormatter\Unparser;
class Formatter
{
protected $configurationCallbacks = [];
protected $parsingCallbacks = [];
protected $renderingCallbacks = [];
/**
* @var Repository
*/
@@ -47,6 +53,21 @@ class Formatter
$this->cacheDir = $cacheDir;
}
public function addConfigurationCallback($callback)
{
$this->configurationCallbacks[] = $callback;
}
public function addParsingCallback($callback)
{
$this->parsingCallbacks[] = $callback;
}
public function addRenderingCallback($callback)
{
$this->renderingCallbacks[] = $callback;
}
/**
* Parse text.
*
@@ -58,8 +79,13 @@ class Formatter
{
$parser = $this->getParser($context);
// Deprecated in beta 15, remove in beta 16
$this->events->dispatch(new Parsing($parser, $context, $text));
foreach ($this->parsingCallbacks as $callback) {
$text = $callback($parser, $context, $text);
}
return $parser->parse($text);
}
@@ -75,8 +101,13 @@ class Formatter
{
$renderer = $this->getRenderer();
// Deprecated in beta 15, remove in beta 16
$this->events->dispatch(new Rendering($renderer, $context, $xml, $request));
foreach ($this->renderingCallbacks as $callback) {
$xml = $callback($renderer, $context, $xml, $request);
}
return $renderer->render($xml);
}
@@ -122,8 +153,13 @@ class Formatter
$configurator->Autolink;
$configurator->tags->onDuplicate('replace');
// Deprecated in beta 15, remove in beta 16
$this->events->dispatch(new Configuring($configurator));
foreach ($this->configurationCallbacks as $callback) {
$callback($configurator);
}
$this->configureExternalLinks($configurator);
return $configurator;

View File

@@ -64,8 +64,9 @@ class ForumServiceProvider extends AbstractServiceProvider
HttpMiddleware\StartSession::class,
HttpMiddleware\RememberFromCookie::class,
HttpMiddleware\AuthenticateWithSession::class,
HttpMiddleware\CheckCsrfToken::class,
HttpMiddleware\SetLocale::class,
'flarum.forum.route_resolver',
HttpMiddleware\CheckCsrfToken::class,
HttpMiddleware\ShareErrorsFromSession::class
];
});
@@ -78,6 +79,10 @@ class ForumServiceProvider extends AbstractServiceProvider
);
});
$this->app->bind('flarum.forum.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.forum.routes'));
});
$this->app->singleton('flarum.forum.handler', function () {
$pipe = new MiddlewarePipe;
@@ -85,7 +90,7 @@ class ForumServiceProvider extends AbstractServiceProvider
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\DispatchRoute($this->app->make('flarum.forum.routes')));
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
return $pipe;
});
@@ -198,8 +203,8 @@ class ForumServiceProvider extends AbstractServiceProvider
$factory = $this->app->make(RouteHandlerFactory::class);
$defaultRoute = $this->app->make('flarum.settings')->get('default_route');
if (isset($routes->getRouteData()[0]['GET'][$defaultRoute])) {
$toDefaultController = $routes->getRouteData()[0]['GET'][$defaultRoute];
if (isset($routes->getRouteData()[0]['GET'][$defaultRoute]['handler'])) {
$toDefaultController = $routes->getRouteData()[0]['GET'][$defaultRoute]['handler'];
} else {
$toDefaultController = $factory->toForum(Content\Index::class);
}

View File

@@ -18,6 +18,16 @@ use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractValidator
{
/**
* @var array
*/
protected $configuration = [];
public function addConfiguration($callable)
{
$this->configuration[] = $callable;
}
/**
* @var array
*/
@@ -92,10 +102,17 @@ abstract class AbstractValidator
$validator = $this->validator->make($attributes, $rules, $this->getMessages());
/**
* @deprecated in beta 15, removed in beta 16.
*/
$this->events->dispatch(
new Validating($this, $validator)
);
foreach ($this->configuration as $callable) {
$callable($this, $validator);
}
return $validator;
}
}

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

@@ -0,0 +1,36 @@
<?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\Foundation;
use Illuminate\Contracts\Container\Container;
class ContainerUtil
{
/**
* Wraps a callback so that string-based invokable classes get resolved only when actually used.
*
* @internal Backwards compatability not guaranteed.
*
* @param callable|string $callback: A callable, or a ::class attribute of an invokable class
* @param Container $container
*/
public static function wrapCallback($callback, Container $container)
{
if (is_string($callback)) {
$callback = function () use ($container, $callback) {
$callback = $container->make($callback);
return call_user_func_array($callback, func_get_args());
};
}
return $callback;
}
}

View File

@@ -13,6 +13,7 @@ use Flarum\Foundation\AbstractValidator;
use Illuminate\Validation\Validator;
/**
* @deprecated in Beta 15, remove in beta 16. Use the Validator extender instead.
* The `Validating` event is called when a validator instance for a
* model is being built. This event can be used to add custom rules/extensions
* to the validator for when validation takes place.

View File

@@ -9,7 +9,7 @@
namespace Flarum\Foundation;
use Flarum\Http\Middleware\DispatchRoute;
use Flarum\Http\Middleware as HttpMiddleware;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\Container;
@@ -85,8 +85,9 @@ class InstalledApp implements AppInterface
$pipe = new MiddlewarePipe;
$pipe->pipe(new BasePath($this->basePath()));
$pipe->pipe(
new DispatchRoute($this->container->make('flarum.update.routes'))
new HttpMiddleware\ResolveRoute($this->container->make('flarum.update.routes'))
);
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
return $pipe;
}

View File

@@ -19,7 +19,7 @@ class HttpServiceProvider extends AbstractServiceProvider
public function register()
{
$this->app->singleton('flarum.http.csrfExemptPaths', function () {
return ['/api/token'];
return ['token'];
});
$this->app->bind(Middleware\CheckCsrfToken::class, function ($app) {

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();

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