1
0
mirror of https://github.com/flarum/core.git synced 2025-08-17 22:01:44 +02:00

Compare commits

...

27 Commits

Author SHA1 Message Date
Daniël Klabbers
f8edc2d827 npm audit fix 2020-12-20 20:55:51 +01:00
flarum-bot
62235a16ca Bundled output for commit 36c55e8f69 [skip ci] 2020-12-20 17:15:07 +00:00
Sami Mazouz
36c55e8f69 Add ExtensionPermissionGrid to compat (#2501) 2020-12-20 12:14:00 -05:00
Daniël Klabbers
859f014539 beta 15 changelog and version constant 2020-12-18 20:02:22 +01:00
Daniël Klabbers
06e1d21331 Fixes validation failures of avatars that are jpg/jpeg (#2497)
Due to a commit by @fabpot in october, the mimetypes symfony class
now re-orders the shortened mimetypes that are returned when looking
up based on header mimetype. Our validator uses the first key, pops
the prefix off and then matches against our hardcoded array.

I've added a constraint to symfony/mime ^5.2.0 which ships with this change.
This constraint is fully compatible with our current lineup. In addition
I changed the hardcoded array to use the first entry from symfony mime types
now `jpg` instead of `jpeg`.
2020-12-16 13:53:17 -05:00
flarum-bot
fd5de6929e Bundled output for commit 84b1666b24 [skip ci] 2020-12-15 22:50:49 +00:00
Alexander Skvortsov
84b1666b24 Support multiple callback-based settings per-extension 2020-12-15 17:49:24 -05:00
Alexander Skvortsov
0c61fcc61c Clarify that request argument of render callbacks for Formatter must be either nullable or omitted 2020-12-14 17:20:35 -05:00
Alexander Skvortsov
8e25bcb68f Deprecate CheckingForFlooding
This should have been done earlier as part of the ThrottleApi PR
2020-12-14 17:12:05 -05:00
flarum-bot
fad783547c Bundled output for commit 210a6b3e25 [skip ci] 2020-12-14 19:07:44 +00:00
Alexander Skvortsov
210a6b3e25 Fix scroll on long discussions
- Anchor scroll when inserting post placeholders
- Indicate that pages are loading at start of `loadPage`, which allows `onscroll` to not request that multiple pages be loaded at the same time

These changes are particularly applicable to firefox, where previously, dozens of posts could be skipped at a time if scroll up was held while at the top of the viewport.
2020-12-14 14:06:32 -05:00
Ian Morland
73409184b9 Fix wrong namespace in docblock (#2494) 2020-12-12 15:36:25 -05:00
Alexander Skvortsov
afe038699e Fix composer json attribute path to links override section 2020-12-08 19:29:59 -05:00
Sami Mazouz
649851d356 Remove header bottom border (#2489) 2020-12-08 19:15:14 -05:00
Alexander Skvortsov
d1dfa758e4 Policy Extender and Tests (#2461)
Policy application has also been refactored, so that policies return one of `allow`, `deny`, `forceAllow`, `forceDeny`. The result of a set of policies is no longer the first non-null result, but rather the highest priority result (forceDeny > forceAllow > deny > allow, so if a single forceDeny is present, that beats out all other returned results). This removes order in which extensions boot as a factor.
2020-12-08 19:10:06 -05:00
Alexander Skvortsov
8901073d12 Model Visibility Scoping Extender and Tests (#2460) 2020-12-07 20:02:46 -05:00
flarum-bot
e0437d237a Bundled output for commit 07a43f52b4 [skip ci] 2020-12-07 20:15:49 +00:00
Charlie
07a43f52b4 AdminUX Overhaul Small Patches (#2468) 2020-12-07 15:14:22 -05:00
flarum-bot
9e9118fa0d Bundled output for commit 4679448300 [skip ci] 2020-12-07 18:35:10 +00:00
Matt Kilgore
4679448300 Slug Driver Support (#2456)
- Support slug drivers for core's sluggable models, easily extends to other models
- Add automated testing for affected single-model API routes
- Fix nickname selection UI
- Serialize slugs as `slug` attribute
- Make min search length a constant
2020-12-07 13:33:42 -05:00
flarum-bot
ef4bf8128e Bundled output for commit 67a2aac635 [skip ci] 2020-12-07 18:26:51 +00:00
David Sevilla Martín
67a2aac635 Replace forum and admin global compat exports with a Proxy to allow namespace use (#2488) 2020-12-07 13:25:24 -05:00
Sami Mazouz
51a97fb12e ApiController Extender and Tests (#2451) 2020-12-06 15:07:48 -05:00
Sami Mazouz
056d420c7b Pass callback wrapper parameters by reference (#2485)
Because invokable class objects are not directly called and instead it's the callback wrapper that calls these objects, it's currently not possible to receive arguments by reference on an invokable class.

To fix this we pass the arguments by reference by default when calling the object in the callback wrapper.
2020-12-06 14:58:45 -05:00
Sami Mazouz
cfa533ebd6 Add Settings Extender (#2452) 2020-12-04 17:20:06 -05:00
Alexander Skvortsov
eed407812f User Preferences Extender and Tests (#2463) 2020-12-04 15:45:08 -05:00
Daniël Klabbers
641619e820 Fixes issue with the worker defaulting to the illuminate queue manager (#2481)
We are instantiating our own queue handling factory which returns the
flarum.queue.connection binding no matter what. The queue Worker and
other queue related code rely on this manager to get its thing going.
Therefor we need to re-use our own factory everywhere, including in
the worker.
2020-12-02 13:19:25 -05:00
101 changed files with 3962 additions and 667 deletions

View File

@@ -1,12 +1,70 @@
# Changelog
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
### Added
- Slug drivers support (https://github.com/flarum/core/pull/2456).
- Notification type extender (https://github.com/flarum/core/pull/2424).
- Validation extender (https://github.com/flarum/core/pull/2102).
- Post extender (https://github.com/flarum/core/pull/2101).
- Notification channel extender (https://github.com/flarum/core/pull/2432).
- Service provider extender (https://github.com/flarum/core/pull/2437).
- API serializer extender (https://github.com/flarum/core/pull/2438).
- User preferences extender (https://github.com/flarum/core/pull/2463).
- Settings extender (https://github.com/flarum/core/pull/2452).
- ApiController extender (https://github.com/flarum/core/pull/2451).
- Model visibility extender (https://github.com/flarum/core/pull/2460).
- Policy extender (https://github.com/flarum/core/pull/2461).
### Changed
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
### Fixed
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
### Removed
- MomentJS alias (https://github.com/flarum/core/pull/2428).
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
### Deprecated
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
## [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)
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.

View File

@@ -79,6 +79,7 @@
"symfony/config": "^4.3.4",
"symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.4",
"symfony/mime": "^5.2.0",
"symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"tobscure/json-api": "^0.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

6
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

6
js/package-lock.json generated
View File

@@ -3382,9 +3382,9 @@
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
},
"ini": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"interpret": {
"version": "1.2.0",

View File

@@ -18,6 +18,7 @@ import ExtensionPage from './components/ExtensionPage';
import ExtensionLinkButton from './components/ExtensionLinkButton';
import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
import MailPage from './components/MailPage';
import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal';
@@ -52,6 +53,7 @@ export default Object.assign(compat, {
'components/ExtensionLinkButton': ExtensionLinkButton,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal,

View File

@@ -21,6 +21,34 @@ export default class AdminNav extends Component {
);
}
oncreate(vnode) {
super.oncreate(vnode);
this.scrollToActive();
}
onupdate() {
this.scrollToActive();
}
scrollToActive() {
const children = $('.Dropdown-menu').children('.active');
const nav = $('#admin-navigation');
const time = app.previous.type ? 250 : 0;
if (
children.length > 0 &&
(children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop())
) {
nav.animate(
{
scrollTop: children[0].offsetTop - nav.height() / 2,
},
time
);
}
}
/**
* Build an item list of main links to show in the admin navigation.
*
@@ -29,6 +57,8 @@ export default class AdminNav extends Component {
items() {
const items = new ItemList();
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
items.add(
'dashboard',
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
@@ -88,7 +118,7 @@ export default class AdminNav extends Component {
Object.keys(categorizedExtensions).map((category) => {
if (!this.query()) {
items.add(
category,
`category-${category}`,
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
categories[category]
);
@@ -100,7 +130,7 @@ export default class AdminNav extends Component {
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
items.add(
extension.id,
`extension-${extension.id}`,
<ExtensionLinkButton
href={app.route('extension', { id: extension.id })}
extensionId={extension.id}

View File

@@ -25,10 +25,6 @@ export default class BasicsPage extends Page {
'welcome_message',
'display_name_driver',
];
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
@@ -42,8 +38,29 @@ export default class BasicsPage extends Page {
this.displayNameOptions[identifier] = identifier;
}, this);
this.slugDriverOptions = {};
Object.keys(app.data.slugDrivers).forEach((model) => {
this.fields.push(`slug_driver_${model}`);
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
});
});
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
Object.keys(app.data.slugDrivers).forEach((model) => {
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
this.values[`slug_driver_${model}`]('default');
}
});
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
@@ -132,20 +149,30 @@ export default class BasicsPage extends Page {
]
)}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
bidi: this.values.display_name_driver,
}),
]
)
: ''}
{Object.keys(this.displayNameOptions).length > 1 ? (
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
<Select
options={this.displayNameOptions}
value={this.values.display_name_driver()}
onchange={this.values.display_name_driver}
></Select>
</FieldSet>
) : (
''
)}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return (
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
</FieldSet>
);
}
})}
{Button.component(
{

View File

@@ -12,7 +12,6 @@ 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 {
@@ -30,6 +29,7 @@ export default class ExtensionPage extends Page {
support: 'fas fa-life-ring',
website: 'fas fa-link',
donate: 'fas fa-donate',
source: 'fas fa-code',
};
// Backwards compatibility layer will be removed in
@@ -49,7 +49,7 @@ export default class ExtensionPage extends Page {
{this.header()}
{!this.isEnabled() ? (
<div className="container">
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h2>
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
</div>
) : (
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
@@ -105,7 +105,7 @@ export default class ExtensionPage extends Page {
{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>
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
)}
</div>
</div>,
@@ -130,7 +130,7 @@ export default class ExtensionPage extends Page {
<div className="Form-group">{this.submitButton()}</div>
</div>
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h2>
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
)}
</div>
</div>
@@ -170,17 +170,15 @@ export default class ExtensionPage extends Page {
infoItems() {
const items = new ItemList();
if (this.extension.authors) {
const links = this.extension.links;
if (links.authors.length) {
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;
links.authors.map((author) => {
authors.push(
<Link href={link} external={true} target="_blank">
{this.extension.authors[author].name}
<Link href={author.link} external={true} target="_blank">
{author.name}
</Link>
);
});
@@ -188,35 +186,17 @@ export default class ExtensionPage extends Page {
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],
};
if (links[field]) {
items.add(
field,
<LinkButton href={links[field]} icon={this.infoFields[field]} external={true} target="_blank">
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
</LinkButton>
);
}
});
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;
}
@@ -233,6 +213,9 @@ export default class ExtensionPage extends Page {
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type.
*
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
* context to include custom JSX elements.
*
* @example
*
* {
@@ -258,6 +241,10 @@ export default class ExtensionPage extends Page {
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
if (typeof entry === 'function') {
return entry.call(this);
}
const setting = entry.setting;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {

View File

@@ -15,33 +15,31 @@ export default class ExtensionsWidget extends DashboardWidget {
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>
{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>
);
}

View File

@@ -10,8 +10,9 @@ export { app };
// Export public API
// Export compat API
import compat from './compat';
import compatObj from './compat';
import proxifyCompat from '../common/utils/proxifyCompat';
compat.app = app;
compatObj.app = app;
export { compat };
export const compat = proxifyCompat(compatObj, 'admin');

View File

@@ -26,6 +26,8 @@ export default class ExtensionData {
/**
* This function registers your settings with Flarum
*
* It takes either a settings object or a callback.
*
* @example
*
* .registerSetting({
@@ -42,6 +44,14 @@ export default class ExtensionData {
registerSetting(content, priority = 0) {
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
// Callbacks can be passed in instead of settings to display custom content.
// By default, they will be added with the `null` key, since they don't have a `.setting` attr.
// To support multiple such items for one extension, we assign a random ID.
// 36 is arbitrary length, but makes collisions very unlikely.
if (typeof content === 'function') {
content.setting = Math.random().toString(36);
}
this.data[this.currentExtension].settings.add(content.setting, content, priority);
return this;

View File

@@ -21,6 +21,7 @@ import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril';
import proxifyCompat from './utils/proxifyCompat';
import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
@@ -94,6 +95,7 @@ export default {
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/proxifyCompat': proxifyCompat,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,

View File

@@ -10,6 +10,7 @@ export default class User extends Model {}
Object.assign(User.prototype, {
username: Model.attribute('username'),
slug: Model.attribute('slug'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),

View File

@@ -0,0 +1,10 @@
export default (compat: { [key: string]: any }, namespace: string) => {
// regex to replace common/ and NAMESPACE/ for core & core extensions
// e.g. admin/utils/extract --> utils/extract
// e.g. tags/common/utils/sortTags --> tags/utils/sortTags
const regex = new RegExp(`(\\w+\\/)?(${namespace}|common)\\/`);
return new Proxy(compat, {
get: (obj, prop: string) => obj[prop] || obj[prop.replace(regex, '$1')],
});
};

View File

@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable';
import extractText from '../../common/utils/extractText';
import classList from '../../common/utils/classList';
import DiscussionPage from './DiscussionPage';
import { escapeRegExp } from 'lodash-es';
/**
@@ -156,9 +157,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
active() {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
}
/**

View File

@@ -109,7 +109,7 @@ export default class DiscussionPage extends Page {
} else {
const params = this.requestParams();
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
}
m.redraw();
@@ -123,6 +123,7 @@ export default class DiscussionPage extends Page {
*/
requestParams() {
return {
bySlug: true,
page: { near: this.near },
};
}

View File

@@ -149,6 +149,11 @@ export default class PostStream extends Component {
*/
onscroll(top = window.pageYOffset) {
if (this.stream.paused) return;
this.updateScrubber(top);
if (this.stream.pagesLoading) return;
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
@@ -174,8 +179,6 @@ export default class PostStream extends Component {
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
this.updateScrubber(top);
}
updateScrubber(top = window.pageYOffset) {

View File

@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
* - state: SearchState instance.
*/
export default class Search extends Component {
static MIN_SEARCH_LEN = 3;
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
@@ -152,7 +154,7 @@ export default class Search extends Component {
search.searchTimeout = setTimeout(() => {
if (state.isCached(query)) return;
if (query.length >= 3) {
if (query.length >= Search.MIN_SEARCH_LEN) {
search.sources.map((source) => {
if (!source.search) return;

View File

@@ -102,7 +102,7 @@ export default class UserPage extends Page {
});
if (!this.user) {
app.store.find('users', username).then(this.show.bind(this));
app.store.find('users', username, { bySlug: true }).then(this.show.bind(this));
}
}

View File

@@ -15,8 +15,9 @@ export { app };
// export { IndexPage, DicsussionList } from './components';
// Export compat API
import compat from './compat';
import compatObj from './compat';
import proxifyCompat from '../common/utils/proxifyCompat';
compat.app = app;
compatObj.app = app;
export { compat };
export const compat = proxifyCompat(compatObj, 'forum');

View File

@@ -1,15 +1,6 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
import DiscussionPage from '../components/DiscussionPage';
/**
* This isn't exported as it is a temporary measure.
* A more robust system will be implemented alongside UTF-8 support in beta 15.
*/
function getDiscussionIdFromSlug(slug: string | undefined) {
if (!slug) return;
return slug.split('-')[0];
}
/**
* A custom route resolver for DiscussionPage that generates the same key to all posts
* on the same discussion. It triggers a scroll when going from one post to another
@@ -18,17 +9,32 @@ function getDiscussionIdFromSlug(slug: string | undefined) {
export default class DiscussionPageResolver extends DefaultResolver {
static scrollToPostNumber: string | null = null;
/**
* Remove optional parts of a discussion's slug to keep the substring
* that bijectively maps to a discussion object. By default this just
* extracts the numerical ID from the slug. If a custom discussion
* slugging driver is used, this may need to be overriden.
* @param slug
*/
canonicalizeDiscussionSlug(slug: string | undefined) {
if (!slug) return;
return slug.split('-')[0];
}
/**
* @inheritdoc
*/
makeKey() {
const params = { ...m.route.param() };
if ('near' in params) {
delete params.near;
}
params.id = getDiscussionIdFromSlug(params.id);
params.id = this.canonicalizeDiscussionSlug(params.id);
return this.routeName.replace('.near', '') + JSON.stringify(params);
}
onmatch(args, requestedPath, route) {
if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) {
if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) {
// By default, the first post number of any discussion is 1
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
}

View File

@@ -34,9 +34,8 @@ export default function (app) {
* @return {String}
*/
app.route.discussion = (discussion, near) => {
const slug = discussion.slug();
return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
id: discussion.slug(),
near: near && near !== 1 ? near : undefined,
});
};
@@ -59,7 +58,7 @@ export default function (app) {
*/
app.route.user = (user) => {
return app.route('user', {
username: user.username(),
username: user.slug(),
});
};
}

View File

@@ -238,23 +238,26 @@ class PostStreamState {
* @param {Boolean} backwards
*/
loadPage(start, end, backwards = false) {
m.redraw();
this.pagesLoading++;
const redraw = () => {
if (start < this.visibleStart || end > this.visibleEnd) return;
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, m.redraw.sync);
};
redraw();
this.loadPageTimeouts[start] = setTimeout(
() => {
this.loadRange(start, end).then(() => {
if (start >= this.visibleStart && end <= this.visibleEnd) {
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw.sync());
}
redraw();
this.pagesLoading--;
});
this.loadPageTimeouts[start] = null;
},
this.pagesLoading ? 1000 : 0
this.pagesLoading - 1 ? 1000 : 0
);
this.pagesLoading++;
}
/**

View File

@@ -11,6 +11,7 @@
.AdminHeader-description {
margin: 0;
color: @control-color;
}
.icon {

View File

@@ -41,16 +41,13 @@
}
@media @tablet {
.item-search{
display: none;
}
.ExtensionItem, .item-search {
display: none !important;
}
.ExtensionListTitle {
display: none !important;
.AdminNav {
.item-search,
li[class^="item-category"],
li[class^="item-extension"],
.AdminLinkButton-description {
display: none !important;
}
}
}
@@ -80,7 +77,7 @@
}
@media @desktop, @desktop-hd {
@media @desktop-up {
.App-nav {
position: absolute;
top: @header-height;
@@ -107,36 +104,47 @@
margin-bottom: 20px;
}
.item-category-core {
> .ExtensionListTitle {
margin-top: 10px;
}
}
> li {
> a {
padding: 10px 10px 10px 45px;
display: block;
text-decoration: none;
}
> a,
> a:hover,
&.active > a {
color: @text-color;
}
> a:hover {
background: @control-bg;
}
&.active > a {
background: @primary-color;
background: @control-color;
font-weight: normal;
color: @body-bg;
.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;
@@ -152,7 +160,7 @@
.ExtensionListTitle {
color: @muted-color;
text-transform: uppercase;
margin: 25px 0 15px 15px;
margin: 25px 0 8px 15px;
}
.ExtensionIcon {
@@ -180,6 +188,11 @@
}
.AdminLinkButton-description {
white-space: normal;
padding-left: 5px;
}
.ExtensionListItem-Dot {
height: 10px;
width: 10px;

View File

@@ -10,17 +10,21 @@
border-radius: @border-radius;
padding: 20px;
margin-bottom: 20px;
.Button {
.Button--color(@control-color, @body-bg)
}
}
.StatusWidget {
color: @muted-color;
> ul {
>ul {
margin: 0;
padding: 0;
list-style-type: none;
> li {
>li {
display: inline-block;
margin-right: 30px;
vertical-align: middle;
@@ -31,6 +35,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
&.item-tools {
float: right;
margin-right: 0;

View File

@@ -119,6 +119,11 @@
}
}
.ExtensionPage-settings, .ExtensionPage-permissions {
.ExtensionPage-subHeader {
margin: 5px 0px;
}
}
.ExtensionPage-settings {
margin-top: 20px;
@@ -132,7 +137,6 @@
.ExtensionPage-subHeader {
color: @muted-color;
font-weight: normal;
text-align: center;
}

View File

@@ -4,77 +4,75 @@
}
.ExtensionsWidget-list {
> .container {
padding: 0;
background-color: @body-bg;
padding: 0;
background-color: @body-bg;
.ExtensionList-Category {
background: @control-bg;
padding: 20px 0 20px 20px;
margin-bottom: 20px;
border-radius: @border-radius;
.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;
.ExtensionList-Label {
margin-top: 0;
color: @muted-color;
}
}
.ExtensionListItem-icon {
opacity: 0.5;
.ExtensionGroup {
margin-bottom: 20px;
h3 {
color: @muted-color;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 10px;
}
}
.ExtensionListItem {
transition: .15s ease-in-out;
.ExtensionList {
padding: 0;
list-style: none;
display: grid;
grid-gap: 10px;
grid-template-columns: repeat(auto-fit, 90px);
margin-bottom: 0;
&:hover {
transform: scale(1.05);
}
.ExtensionListItem-title {
> li {
text-align: left;
position: relative;
display: block;
text-align: center;
margin-top: 5px;
color: @text-color;
}
}
}
a:hover {
text-decoration: none;
}
.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;
}
}

View File

@@ -5,7 +5,11 @@
display: block;
margin-left: 30px;
overflow-x: auto;
padding: 8px 0 8px;
padding: 10px 0 8px;
> .container {
padding: 0 10px;
}
}
.Group {
width: 90px;
@@ -42,6 +46,7 @@
.PermissionGrid {
white-space: nowrap;
padding-left: 0 12px;
td, th {
padding: 5px;
@@ -117,7 +122,7 @@
}
.PermissionGrid-section {
td, th {
padding-top: 20px;
padding-top: 10px;
}
}
.PermissionGrid-child {

View File

@@ -231,7 +231,8 @@
.header-background();
padding: 8px;
position: absolute;
border-bottom: 0;
.affix & {
position: fixed;
}

View File

@@ -75,6 +75,9 @@ class AdminPayload
$document->payload['extensions'] = $this->extensions->getExtensions()->toArray();
$document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers'));
$document->payload['slugDrivers'] = array_map(function ($resourceDrivers) {
return array_keys($resourceDrivers);
}, $this->container->make('flarum.http.slugDrivers'));
$document->payload['phpVersion'] = PHP_VERSION;
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;

View File

@@ -82,6 +82,16 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
*/
protected static $events;
/**
* @var array
*/
protected static $beforeDataCallbacks = [];
/**
* @var array
*/
protected static $beforeSerializationCallbacks = [];
/**
* {@inheritdoc}
*/
@@ -89,12 +99,30 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
{
$document = new Document;
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$beforeDataCallbacks[$class])) {
foreach (static::$beforeDataCallbacks[$class] as $callback) {
$callback($this);
}
}
}
// Deprected in beta 15, removed in beta 16
static::$events->dispatch(
new WillGetData($this)
);
$data = $this->data($request, $document);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$beforeSerializationCallbacks[$class])) {
foreach (static::$beforeSerializationCallbacks[$class] as $callback) {
$callback($this, $data, $request, $document);
}
}
}
// Deprecated in beta 15, removed in beta 16
static::$events->dispatch(
new WillSerializeData($this, $data, $request, $document)
);
@@ -197,6 +225,106 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
return new Parameters($request->getQueryParams());
}
/**
* Set the serializer that will serialize data for the endpoint.
*
* @param string $serializer
*/
public function setSerializer(string $serializer)
{
$this->serializer = $serializer;
}
/**
* Include the given relationship by default.
*
* @param string|array $name
*/
public function addInclude($name)
{
$this->include = array_merge($this->include, (array) $name);
}
/**
* Don't include the given relationship by default.
*
* @param string|array $name
*/
public function removeInclude($name)
{
$this->include = array_diff($this->include, (array) $name);
}
/**
* Make the given relationship available for inclusion.
*
* @param string|array $name
*/
public function addOptionalInclude($name)
{
$this->optionalInclude = array_merge($this->optionalInclude, (array) $name);
}
/**
* Don't allow the given relationship to be included.
*
* @param string|array $name
*/
public function removeOptionalInclude($name)
{
$this->optionalInclude = array_diff($this->optionalInclude, (array) $name);
}
/**
* Set the default number of results.
*
* @param int $limit
*/
public function setLimit(int $limit)
{
$this->limit = $limit;
}
/**
* Set the maximum number of results.
*
* @param int $max
*/
public function setMaxLimit(int $max)
{
$this->maxLimit = $max;
}
/**
* Allow sorting results by the given field.
*
* @param string|array $field
*/
public function addSortField($field)
{
$this->sortFields = array_merge($this->sortFields, (array) $field);
}
/**
* Disallow sorting results by the given field.
*
* @param string|array $field
*/
public function removeSortField($field)
{
$this->sortFields = array_diff($this->sortFields, (array) $field);
}
/**
* Set the default sort order for the results.
*
* @param array $sort
*/
public function setSort(array $sort)
{
$this->sort = $sort;
}
/**
* @return Dispatcher
*/
@@ -228,4 +356,30 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
{
static::$container = $container;
}
/**
* @param string $controllerClass
* @param callable $callback
*/
public static function addDataPreparationCallback(string $controllerClass, callable $callback)
{
if (! isset(static::$beforeDataCallbacks[$controllerClass])) {
static::$beforeDataCallbacks[$controllerClass] = [];
}
static::$beforeDataCallbacks[$controllerClass][] = $callback;
}
/**
* @param string $controllerClass
* @param callable $callback
*/
public static function addSerializationPreparationCallback(string $controllerClass, callable $callback)
{
if (! isset(static::$beforeSerializationCallbacks[$controllerClass])) {
static::$beforeSerializationCallbacks[$controllerClass] = [];
}
static::$beforeSerializationCallbacks[$controllerClass][] = $callback;
}
}

View File

@@ -12,6 +12,7 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Http\SlugManager;
use Flarum\Post\PostRepository;
use Flarum\User\User;
use Illuminate\Support\Arr;
@@ -31,6 +32,11 @@ class ShowDiscussionController extends AbstractShowController
*/
protected $posts;
/**
* @var SlugManager
*/
protected $slugManager;
/**
* {@inheritdoc}
*/
@@ -61,11 +67,13 @@ class ShowDiscussionController extends AbstractShowController
/**
* @param \Flarum\Discussion\DiscussionRepository $discussions
* @param \Flarum\Post\PostRepository $posts
* @param \Flarum\Http\SlugManager $slugManager
*/
public function __construct(DiscussionRepository $discussions, PostRepository $posts)
public function __construct(DiscussionRepository $discussions, PostRepository $posts, SlugManager $slugManager)
{
$this->discussions = $discussions;
$this->posts = $posts;
$this->slugManager = $slugManager;
}
/**
@@ -77,7 +85,11 @@ class ShowDiscussionController extends AbstractShowController
$actor = $request->getAttribute('actor');
$include = $this->extractInclude($request);
$discussion = $this->discussions->findOrFail($discussionId, $actor);
if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
$discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($discussionId, $actor);
} else {
$discussion = $this->discussions->findOrFail($discussionId, $actor);
}
if (in_array('posts', $include)) {
$postRelationships = $this->getPostRelationships($include);

View File

@@ -11,6 +11,8 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\SlugManager;
use Flarum\User\User;
use Flarum\User\UserRepository;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
@@ -29,15 +31,22 @@ class ShowUserController extends AbstractShowController
public $include = ['groups'];
/**
* @var \Flarum\User\UserRepository
* @var SlugManager
*/
protected $slugManager;
/**
* @var UserRepository
*/
protected $users;
/**
* @param \Flarum\User\UserRepository $users
* @param SlugManager $slugManager
* @param UserRepository $users
*/
public function __construct(UserRepository $users)
public function __construct(SlugManager $slugManager, UserRepository $users)
{
$this->slugManager = $slugManager;
$this->users = $users;
}
@@ -47,17 +56,18 @@ class ShowUserController extends AbstractShowController
protected function data(ServerRequestInterface $request, Document $document)
{
$id = Arr::get($request->getQueryParams(), 'id');
if (! is_numeric($id)) {
$id = $this->users->getIdForUsername($id);
}
$actor = $request->getAttribute('actor');
if ($actor->id == $id) {
if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
$user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor);
} else {
$user = $this->users->findOrFail($id, $actor);
}
if ($actor->id === $user->id) {
$this->serializer = CurrentUserSerializer::class;
}
return $this->users->findOrFail($id, $actor);
return $user;
}
}

View File

@@ -12,6 +12,9 @@ namespace Flarum\Api\Event;
use Flarum\Api\Controller\AbstractSerializeController;
use Illuminate\Support\Arr;
/**
* @deprecated in beta 15, removed in beta 16
*/
class WillGetData
{
/**

View File

@@ -13,6 +13,9 @@ use Flarum\Api\Controller\AbstractSerializeController;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
/**
* @deprecated in beta 15, removed in beta 16
*/
class WillSerializeData
{
/**

View File

@@ -10,6 +10,7 @@
namespace Flarum\Api\Serializer;
use Flarum\Discussion\Discussion;
use Flarum\Http\SlugManager;
use InvalidArgumentException;
class BasicDiscussionSerializer extends AbstractSerializer
@@ -19,6 +20,16 @@ class BasicDiscussionSerializer extends AbstractSerializer
*/
protected $type = 'discussions';
/**
* @var SlugManager
*/
protected $slugManager;
public function __construct(SlugManager $slugManager)
{
$this->slugManager = $slugManager;
}
/**
* {@inheritdoc}
*
@@ -35,7 +46,7 @@ class BasicDiscussionSerializer extends AbstractSerializer
return [
'title' => $discussion->title,
'slug' => $discussion->slug,
'slug' => $this->slugManager->forResource(Discussion::class)->toSlug($discussion),
];
}

View File

@@ -9,6 +9,7 @@
namespace Flarum\Api\Serializer;
use Flarum\Http\SlugManager;
use Flarum\User\User;
use InvalidArgumentException;
@@ -19,6 +20,16 @@ class BasicUserSerializer extends AbstractSerializer
*/
protected $type = 'users';
/**
* @var SlugManager
*/
protected $slugManager;
public function __construct(SlugManager $slugManager)
{
$this->slugManager = $slugManager;
}
/**
* {@inheritdoc}
*
@@ -36,7 +47,8 @@ class BasicUserSerializer extends AbstractSerializer
return [
'username' => $user->username,
'displayName' => $user->display_name,
'avatarUrl' => $user->avatar_url
'avatarUrl' => $user->avatar_url,
'slug' => $this->slugManager->forResource(User::class)->toSlug($user)
];
}

View File

@@ -12,19 +12,49 @@ namespace Flarum\Database;
use Flarum\Event\ScopeModelVisibility;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
trait ScopeVisibilityTrait
{
protected static $visibilityScopers = [];
public static function registerVisibilityScoper($scoper, $ability = null)
{
$model = static::class;
if ($ability === null) {
$ability = '*';
}
if (! Arr::has(static::$visibilityScopers, "$model.$ability")) {
Arr::set(static::$visibilityScopers, "$model.$ability", []);
}
static::$visibilityScopers[$model][$ability][] = $scoper;
}
/**
* Scope a query to only include records that are visible to a user.
*
* @param Builder $query
* @param User $actor
*/
public function scopeWhereVisibleTo(Builder $query, User $actor)
public function scopeWhereVisibleTo(Builder $query, User $actor, string $ability = 'view')
{
static::$dispatcher->dispatch(
new ScopeModelVisibility($query, $actor, 'view')
);
/**
* @deprecated beta 15, remove beta 15
*/
static::$dispatcher->dispatch(new ScopeModelVisibility($query, $actor, $ability));
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
foreach (Arr::get(static::$visibilityScopers, "$class.*", []) as $listener) {
$listener($actor, $query, $ability);
}
foreach (Arr::get(static::$visibilityScopers, "$class.$ability", []) as $listener) {
$listener($actor, $query);
}
}
return $query;
}
}

View File

@@ -0,0 +1,79 @@
<?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\Discussion\Access;
use Flarum\Discussion\Discussion;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
class DiscussionPolicy extends AbstractPolicy
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @param SettingsRepositoryInterface $settings
* @param Dispatcher $events
*/
public function __construct(SettingsRepositoryInterface $settings)
{
$this->settings = $settings;
}
/**
* @param User $actor
* @param string $ability
* @return bool|null
*/
public function can(User $actor, $ability)
{
if ($actor->hasPermission('discussion.'.$ability)) {
return $this->allow();
}
}
/**
* @param User $actor
* @param \Flarum\Discussion\Discussion $discussion
* @return bool|null
*/
public function rename(User $actor, Discussion $discussion)
{
if ($discussion->user_id == $actor->id && $actor->can('reply', $discussion)) {
$allowRenaming = $this->settings->get('allow_renaming');
if ($allowRenaming === '-1'
|| ($allowRenaming === 'reply' && $discussion->participant_count <= 1)
|| ($discussion->created_at->diffInMinutes() < $allowRenaming)) {
return $this->allow();
}
}
}
/**
* @param User $actor
* @param \Flarum\Discussion\Discussion $discussion
* @return bool|null
*/
public function hide(User $actor, Discussion $discussion)
{
if ($discussion->user_id == $actor->id
&& $discussion->participant_count <= 1
&& (! $discussion->hidden_at || $discussion->hidden_user_id == $actor->id)
&& $actor->can('reply', $discussion)
) {
return $this->allow();
}
}
}

View File

@@ -0,0 +1,61 @@
<?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\Discussion\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeDiscussionVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
if ($actor->cannot('viewDiscussions')) {
$query->whereRaw('FALSE');
return;
}
// Hide private discussions by default.
$query->where(function ($query) use ($actor) {
$query->where('discussions.is_private', false)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'viewPrivate');
});
});
// Hide hidden discussions, unless they are authored by the current
// user, or the current user has permission to view hidden discussions.
if (! $actor->hasPermission('discussion.hide')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('discussions.hidden_at')
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'hide');
});
});
}
// Hide discussions with no comments, unless they are authored by the
// current user, or the user is allowed to edit the discussion's posts.
if (! $actor->hasPermission('discussion.editPosts')) {
$query->where(function ($query) use ($actor) {
$query->where('discussions.comment_count', '>', 0)
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'editPosts');
});
});
}
}
}

View File

@@ -1,142 +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\Discussion;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
class DiscussionPolicy extends AbstractPolicy
{
/**
* {@inheritdoc}
*/
protected $model = Discussion::class;
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var Dispatcher
*/
protected $events;
/**
* @param SettingsRepositoryInterface $settings
* @param Dispatcher $events
*/
public function __construct(SettingsRepositoryInterface $settings, Dispatcher $events)
{
$this->settings = $settings;
$this->events = $events;
}
/**
* @param User $actor
* @param string $ability
* @return bool|null
*/
public function can(User $actor, $ability)
{
if ($actor->hasPermission('discussion.'.$ability)) {
return true;
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
{
if ($actor->cannot('viewDiscussions')) {
$query->whereRaw('FALSE');
return;
}
// Hide private discussions by default.
$query->where(function ($query) use ($actor) {
$query->where('discussions.is_private', false)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'viewPrivate')
);
});
});
// Hide hidden discussions, unless they are authored by the current
// user, or the current user has permission to view hidden discussions.
if (! $actor->hasPermission('discussion.hide')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('discussions.hidden_at')
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'hide')
);
});
});
}
// Hide discussions with no comments, unless they are authored by the
// current user, or the user is allowed to edit the discussion's posts.
if (! $actor->hasPermission('discussion.editPosts')) {
$query->where(function ($query) use ($actor) {
$query->where('discussions.comment_count', '>', 0)
->orWhere('discussions.user_id', $actor->id)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'editPosts')
);
});
});
}
}
/**
* @param User $actor
* @param \Flarum\Discussion\Discussion $discussion
* @return bool|null
*/
public function rename(User $actor, Discussion $discussion)
{
if ($discussion->user_id == $actor->id && $actor->can('reply', $discussion)) {
$allowRenaming = $this->settings->get('allow_renaming');
if ($allowRenaming === '-1'
|| ($allowRenaming === 'reply' && $discussion->participant_count <= 1)
|| ($discussion->created_at->diffInMinutes() < $allowRenaming)) {
return true;
}
}
}
/**
* @param User $actor
* @param \Flarum\Discussion\Discussion $discussion
* @return bool|null
*/
public function hide(User $actor, Discussion $discussion)
{
if ($discussion->user_id == $actor->id
&& $discussion->participant_count <= 1
&& (! $discussion->hidden_at || $discussion->hidden_user_id == $actor->id)
&& $actor->can('reply', $discussion)
) {
return true;
}
}
}

View File

@@ -9,6 +9,7 @@
namespace Flarum\Discussion;
use Flarum\Discussion\Access\ScopeDiscussionVisibility;
use Flarum\Discussion\Event\Renamed;
use Flarum\Foundation\AbstractServiceProvider;
@@ -22,11 +23,12 @@ class DiscussionServiceProvider extends AbstractServiceProvider
$events = $this->app->make('events');
$events->subscribe(DiscussionMetadataUpdater::class);
$events->subscribe(DiscussionPolicy::class);
$events->listen(
Renamed::class,
DiscussionRenamedLogger::class
);
Discussion::registerVisibilityScoper(new ScopeDiscussionVisibility(), 'view');
}
}

View File

@@ -0,0 +1,42 @@
<?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\Discussion;
use Flarum\Database\AbstractModel;
use Flarum\Http\SlugDriverInterface;
use Flarum\User\User;
class IdWithTransliteratedSlugDriver implements SlugDriverInterface
{
/**
* @var DiscussionRepository
*/
protected $discussions;
public function __construct(DiscussionRepository $discussions)
{
$this->discussions = $discussions;
}
public function toSlug(AbstractModel $instance): string
{
return $instance->id.(trim($instance->slug) ? '-'.$instance->slug : '');
}
public function fromSlug(string $slug, User $actor): AbstractModel
{
if (strpos($slug, '-')) {
$slug_array = explode('-', $slug);
$slug = $slug_array[0];
}
return $this->discussions->findOrFail($slug, $actor);
}
}

View File

@@ -11,6 +11,9 @@ namespace Flarum\Event;
use Flarum\User\User;
/**
* @deprecated beta 15, removed beta 16
*/
class ConfigureUserPreferences
{
public function add($key, callable $transformer = null, $default = null)

View File

@@ -11,6 +11,9 @@ namespace Flarum\Event;
use Flarum\User\User;
/**
* @deprecated beta 15, remove beta 16
*/
class GetPermission
{
/**

View File

@@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Builder;
/**
* The `ScopeModelVisibility` event allows constraints to be applied in a query
* to fetch a model, effectively scoping that model's visibility to the user.
*
* @deprecated beta 15, remove beta 16
*/
class ScopeModelVisibility
{

View File

@@ -0,0 +1,302 @@
<?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\Controller\AbstractSerializeController;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class ApiController implements ExtenderInterface
{
private $controllerClass;
private $beforeDataCallbacks = [];
private $beforeSerializationCallbacks = [];
private $serializer;
private $addIncludes = [];
private $removeIncludes = [];
private $addOptionalIncludes = [];
private $removeOptionalIncludes = [];
private $limit;
private $maxLimit;
private $addSortFields = [];
private $removeSortFields = [];
private $sort;
/**
* @param string $controllerClass The ::class attribute of the controller you are modifying.
* This controller should extend from \Flarum\Api\Controller\AbstractSerializeController.
*/
public function __construct(string $controllerClass)
{
$this->controllerClass = $controllerClass;
}
/**
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $controller: An instance of this controller.
*
* @return self
*/
public function prepareDataQuery($callback)
{
$this->beforeDataCallbacks[] = $callback;
return $this;
}
/**
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $controller: An instance of this controller.
* - $data: Mixed, can be an array of data or an object (like an instance of Collection or AbstractModel).
* - $request: An instance of \Psr\Http\Message\ServerRequestInterface.
* - $document: An instance of \Tobscure\JsonApi\Document.
*
* The callable should return:
* - An array of additional data to merge with the existing array.
* Or a modified $data array.
*
* @return self
*/
public function prepareDataForSerialization($callback)
{
$this->beforeSerializationCallbacks[] = $callback;
return $this;
}
/**
* Set the serializer that will serialize data for the endpoint.
*
* @param string $serializerClass
* @param callable|string|null $callback
* @return self
*/
public function setSerializer(string $serializerClass, $callback = null)
{
$this->serializer = [$serializerClass, $callback];
return $this;
}
/**
* Include the given relationship by default.
*
* @param string|array $name
* @param callable|string|null $callback
* @return self
*/
public function addInclude($name, $callback = null)
{
$this->addIncludes[] = [$name, $callback];
return $this;
}
/**
* Don't include the given relationship by default.
*
* @param string|array $name
* @param callable|string|null $callback
* @return self
*/
public function removeInclude($name, $callback = null)
{
$this->removeIncludes[] = [$name, $callback];
return $this;
}
/**
* Make the given relationship available for inclusion.
*
* @param string|array $name
* @param callable|string|null $callback
* @return self
*/
public function addOptionalInclude($name, $callback = null)
{
$this->addOptionalIncludes[] = [$name, $callback];
return $this;
}
/**
* Don't allow the given relationship to be included.
*
* @param string|array $name
* @param callable|string|null $callback
* @return self
*/
public function removeOptionalInclude($name, $callback = null)
{
$this->removeOptionalIncludes[] = [$name, $callback];
return $this;
}
/**
* Set the default number of results.
*
* @param int $limit
* @param callable|string|null $callback
* @return self
*/
public function setLimit(int $limit, $callback = null)
{
$this->limit = [$limit, $callback];
return $this;
}
/**
* Set the maximum number of results.
*
* @param int $max
* @param callable|string|null $callback
* @return self
*/
public function setMaxLimit(int $max, $callback = null)
{
$this->maxLimit = [$max, $callback];
return $this;
}
/**
* Allow sorting results by the given field.
*
* @param string|array $field
* @param callable|string|null $callback
* @return self
*/
public function addSortField($field, $callback = null)
{
$this->addSortFields[] = [$field, $callback];
return $this;
}
/**
* Disallow sorting results by the given field.
*
* @param string|array $field
* @param callable|string|null $callback
* @return self
*/
public function removeSortField($field, $callback = null)
{
$this->removeSortFields[] = [$field, $callback];
return $this;
}
/**
* Set the default sort order for the results.
*
* @param array $sort
* @param callable|string|null $callback
* @return self
*/
public function setSort(array $sort, $callback = null)
{
$this->sort = [$sort, $callback];
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$this->beforeDataCallbacks[] = function (AbstractSerializeController $controller) use ($container) {
if (isset($this->serializer) && $this->isApplicable($this->serializer[1], $controller, $container)) {
$controller->setSerializer($this->serializer[0]);
}
foreach ($this->addIncludes as $addingInclude) {
if ($this->isApplicable($addingInclude[1], $controller, $container)) {
$controller->addInclude($addingInclude[0]);
}
}
foreach ($this->removeIncludes as $removingInclude) {
if ($this->isApplicable($removingInclude[1], $controller, $container)) {
$controller->removeInclude($removingInclude[0]);
}
}
foreach ($this->addOptionalIncludes as $addingOptionalInclude) {
if ($this->isApplicable($addingOptionalInclude[1], $controller, $container)) {
$controller->addOptionalInclude($addingOptionalInclude[0]);
}
}
foreach ($this->removeOptionalIncludes as $removingOptionalInclude) {
if ($this->isApplicable($removingOptionalInclude[1], $controller, $container)) {
$controller->removeOptionalInclude($removingOptionalInclude[0]);
}
}
foreach ($this->addSortFields as $addingSortField) {
if ($this->isApplicable($addingSortField[1], $controller, $container)) {
$controller->addSortField($addingSortField[0]);
}
}
foreach ($this->removeSortFields as $removingSortField) {
if ($this->isApplicable($removingSortField[1], $controller, $container)) {
$controller->removeSortField($removingSortField[0]);
}
}
if (isset($this->limit) && $this->isApplicable($this->limit[1], $controller, $container)) {
$controller->setLimit($this->limit[0]);
}
if (isset($this->maxLimit) && $this->isApplicable($this->maxLimit[1], $controller, $container)) {
$controller->setMaxLimit($this->maxLimit[0]);
}
if (isset($this->sort) && $this->isApplicable($this->sort[1], $controller, $container)) {
$controller->setSort($this->sort[0]);
}
};
foreach ($this->beforeDataCallbacks as $beforeDataCallback) {
$beforeDataCallback = ContainerUtil::wrapCallback($beforeDataCallback, $container);
AbstractSerializeController::addDataPreparationCallback($this->controllerClass, $beforeDataCallback);
}
foreach ($this->beforeSerializationCallbacks as $beforeSerializationCallback) {
$beforeSerializationCallback = ContainerUtil::wrapCallback($beforeSerializationCallback, $container);
AbstractSerializeController::addSerializationPreparationCallback($this->controllerClass, $beforeSerializationCallback);
}
}
/**
* @param callable|string|null $callback
* @param AbstractSerializeController $controller
* @param Container $container
* @return bool
*/
private function isApplicable($callback, AbstractSerializeController $controller, Container $container)
{
if (! isset($callback)) {
return true;
}
$callback = ContainerUtil::wrapCallback($callback, $container);
return (bool) $callback($controller);
}
}

View File

@@ -68,7 +68,7 @@ class Formatter implements ExtenderInterface, LifecycleInterface
* - \s9e\TextFormatter\Rendered $renderer
* - mixed $context
* - string $xml: The xml to be rendered.
* - ServerRequestInterface $request
* - ServerRequestInterface $request. This argument MUST either be nullable, or omitted entirely.
*
* The callback should return:
* - string $xml: The xml to be rendered.

54
src/Extend/ModelUrl.php Normal file
View File

@@ -0,0 +1,54 @@
<?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;
use Illuminate\Support\Arr;
class ModelUrl implements ExtenderInterface
{
private $modelClass;
private $slugDrivers = [];
/**
* @param string $modelClass The ::class attribute of the model you are modifying.
* This model should extend from \Flarum\Database\AbstractModel.
*/
public function __construct(string $modelClass)
{
$this->modelClass = $modelClass;
}
/**
* Add a slug driver.
*
* @param string $identifier Identifier for slug driver.
* @param string $driver ::class attribute of driver class, which must implement Flarum\Http\SlugDriverInterface
* @return self
*/
public function addSlugDriver(string $identifier, string $driver)
{
$this->slugDrivers[$identifier] = $driver;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if ($this->slugDrivers) {
$container->extend('flarum.http.slugDrivers', function ($existingDrivers) {
$existingDrivers[$this->modelClass] = array_merge(Arr::get($existingDrivers, $this->modelClass, []), $this->slugDrivers);
return $existingDrivers;
});
}
}
}

View File

@@ -0,0 +1,102 @@
<?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 Exception;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
/**
* Model visibility scoping allows us to scope queries based on the current user.
* The main usage of this is only showing model instances that a user is allowed to see.
*
* This is done by running a query through a series of "scoper" callbacks, which apply
* additional `where`s to the query based on the user.
*
* Scopers are classified under an ability. Calling `whereVisibleTo` on a query
* will apply scopers under the `view` ability. Generally, the main `view` scopers
* can request scoping with other abilities, which provides an entrypoint for extensions
* to modify some restriction to a query.
*
* Scopers registered via `scopeAll` will apply to all queries under a model, regardless
* of the ability, and will accept the ability name as an additional argument.
*/
class ModelVisibility implements ExtenderInterface
{
private $modelClass;
private $scopers = [];
private $allScopers = [];
/**
* @param string $modelClass The ::class attribute of the model you are applying scopers to.
* This model must extend from \Flarum\Database\AbstractModel,
* and use \Flarum\Database\ScopeVisibilityTrait.
*/
public function __construct(string $modelClass)
{
$this->modelClass = $modelClass;
if (! method_exists($modelClass, 'registerVisibilityScoper')) {
throw new Exception("Model $modelClass cannot be visibility scoped as it does not use Flarum\Database\ScopeVisibilityTrait.");
}
}
/**
* Add a scoper for a given ability.
*
* @param callable|string $callback
* @param string $ability, defaults to 'view'
*
* The callback can be a closure or invokable class, and should accept:
* - \Flarum\User\User $actor
* - \Illuminate\Database\Eloquent\Builder $query
*
* @return self
*/
public function scope($callback, $ability = 'view')
{
$this->scopers[$ability][] = $callback;
return $this;
}
/**
* Add a scoper scoper that will always run for this model, regardless of requested ability.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - \Flarum\User\User $actor
* - \Illuminate\Database\Eloquent\Builder $query
* - string $ability
*
* @return self
*/
public function scopeAll($callback)
{
$this->allScopers[] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
foreach ($this->scopers as $ability => $scopers) {
foreach ($scopers as $scoper) {
$this->modelClass::registerVisibilityScoper(ContainerUtil::wrapCallback($scoper, $container), $ability);
}
}
foreach ($this->allScopers as $scoper) {
$this->modelClass::registerVisibilityScoper(ContainerUtil::wrapCallback($scoper, $container));
}
}
}

69
src/Extend/Policy.php Normal file
View File

@@ -0,0 +1,69 @@
<?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\User\Access\AbstractPolicy;
use Illuminate\Contracts\Container\Container;
class Policy implements ExtenderInterface
{
private $globalPolicies = [];
private $modelPolicies = [];
/**
* Add a custom policy for when an ability check is ran without a model instance.
*
* @param string $policy ::class attribute of policy class, which must extend Flarum\User\Access\AbstractPolicy
*/
public function globalPolicy(string $policy)
{
$this->globalPolicies[] = $policy;
return $this;
}
/**
* Add a custom policy for when an ability check is ran on an instance of a model.
*
* @param string $modelClass The ::class attribute of the model you are applying policies to.
* This model should extend from \Flarum\Database\AbstractModel.
* @param string $policy ::class attribute of policy class, which must extend Flarum\User\Access\AbstractPolicy
*/
public function modelPolicy(string $modelClass, string $policy)
{
if (! array_key_exists($modelClass, $this->modelPolicies)) {
$this->modelPolicies[$modelClass] = [];
}
$this->modelPolicies[$modelClass][] = $policy;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.policies', function ($existingPolicies) {
foreach ($this->modelPolicies as $modelClass => $addPolicies) {
if (! array_key_exists($modelClass, $existingPolicies)) {
$existingPolicies[$modelClass] = [];
}
foreach ($addPolicies as $policy) {
$existingPolicies[$modelClass][] = $policy;
}
}
$existingPolicies[AbstractPolicy::GLOBAL] = array_merge($existingPolicies[AbstractPolicy::GLOBAL], $this->globalPolicies);
return $existingPolicies;
});
}
}

63
src/Extend/Settings.php Normal file
View File

@@ -0,0 +1,63 @@
<?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\Api\Serializer\ForumSerializer;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
class Settings implements ExtenderInterface
{
private $settings = [];
/**
* Serialize a setting value to the ForumSerializer attributes.
*
* @param string $attributeName: The attribute name to be used in the ForumSerializer attributes array.
* @param string $key: The key of the setting.
* @param string|callable|null $callback: Optional callback to modify the value before serialization.
* @return $this
*/
public function serializeToForum(string $attributeName, string $key, $callback = null)
{
$this->settings[$key] = compact('attributeName', 'callback');
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if (! empty($this->settings)) {
AbstractSerializer::addMutator(
ForumSerializer::class,
function () use ($container) {
$settings = $container->make(SettingsRepositoryInterface::class);
$attributes = [];
foreach ($this->settings as $key => $setting) {
$value = $settings->get($key, null);
if (isset($setting['callback'])) {
$callback = ContainerUtil::wrapCallback($setting['callback'], $container);
$value = $callback($value);
}
$attributes[$setting['attributeName']] = $value;
}
return $attributes;
}
);
}
}
}

View File

@@ -10,12 +10,14 @@
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\User\User as FlarumUser;
use Illuminate\Contracts\Container\Container;
class User implements ExtenderInterface
{
private $displayNameDrivers = [];
private $groupProcessors = [];
private $preferences = [];
/**
* Add a display name driver.
@@ -51,6 +53,20 @@ class User implements ExtenderInterface
return $this;
}
/**
* Register a new user preference.
*
* @param string $key
* @param callable $transformer
* @param $default
*/
public function registerPreference(string $key, callable $transformer = null, $default = null)
{
$this->preferences[$key] = compact('transformer', 'default');
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.user.display_name.supported_drivers', function ($existingDrivers) {
@@ -60,5 +76,9 @@ class User implements ExtenderInterface
$container->extend('flarum.user.group_processors', function ($existingRelations) {
return array_merge($existingRelations, $this->groupProcessors);
});
foreach ($this->preferences as $key => $preference) {
FlarumUser::registerPreference($key, $preference['transformer'], $preference['default']);
}
}
}

View File

@@ -346,6 +346,49 @@ class Extension implements Arrayable
return null;
}
/**
* Compile a list of links for this extension.
*/
public function getLinks()
{
$links = [];
if (($sourceUrl = $this->composerJsonAttribute('source.url')) || ($sourceUrl = $this->composerJsonAttribute('support.source'))) {
$links['source'] = $sourceUrl;
}
if (($discussUrl = $this->composerJsonAttribute('support.forum'))) {
$links['discuss'] = $discussUrl;
}
if (($documentationUrl = $this->composerJsonAttribute('support.docs'))) {
$links['documentation'] = $documentationUrl;
}
if (($websiteUrl = $this->composerJsonAttribute('homepage'))) {
$links['website'] = $websiteUrl;
}
if (($supportEmail = $this->composerJsonAttribute('support.email'))) {
$links['support'] = "mailto:$supportEmail";
}
if (($funding = $this->composerJsonAttribute('funding')) && count($funding)) {
$links['donate'] = $funding[0]['url'];
}
$links['authors'] = [];
foreach ((array) $this->composerJsonAttribute('authors') as $author) {
$links['authors'][] = [
'name' => Arr::get($author, 'name'),
'link' => Arr::get($author, 'homepage') ?? (Arr::get($author, 'email') ? 'mailto:'.Arr::get($author, 'email') : ''),
];
}
return array_merge($links, $this->composerJsonAttribute('extra.flarum-extension.links') ?? []);
}
/**
* Tests whether the extension has assets.
*
@@ -413,6 +456,7 @@ class Extension implements Arrayable
'hasAssets' => $this->hasAssets(),
'hasMigrations' => $this->hasMigrations(),
'extensionDependencyIds' => $this->getExtensionDependencyIds(),
'links' => $this->getLinks(),
], $this->composerJson);
}
}

View File

@@ -74,9 +74,7 @@ class Discussion
unset($newQueryParams['id']);
$queryString = http_build_query($newQueryParams);
$idWithSlug = $apiDocument->data->id.(trim($apiDocument->data->attributes->slug) ? '-'.$apiDocument->data->attributes->slug : '');
return $this->url->to('forum')->route('discussion', ['id' => $idWithSlug]).
return $this->url->to('forum')->route('discussion', ['id' => $apiDocument->data->attributes->slug]).
($queryString ? '?'.$queryString : '');
};
@@ -106,6 +104,7 @@ class Discussion
*/
protected function getApiDocument(User $actor, array $params)
{
$params['bySlug'] = true;
$response = $this->api->send('Flarum\Api\Controller\ShowDiscussionController', $actor, $params);
$statusCode = $response->getStatusCode();

View File

@@ -54,7 +54,7 @@ class User
$user = $apiDocument->data->attributes;
$document->title = $user->displayName;
$document->canonicalUrl = $this->url->to('forum')->route('user', ['username' => $user->username]);
$document->canonicalUrl = $this->url->to('forum')->route('user', ['username' => $user->slug]);
$document->payload['apiDocument'] = $apiDocument;
return $document;
@@ -70,6 +70,7 @@ class User
*/
protected function getApiDocument(FlarumUser $actor, array $params)
{
$params['bySlug'] = true;
$response = $this->api->send(ShowUserController::class, $actor, $params);
$statusCode = $response->getStatusCode();

View File

@@ -21,7 +21,7 @@ class Application
*
* @var string
*/
const VERSION = '0.1.0-beta.14.1';
const VERSION = '0.1.0-beta.15';
/**
* The IoC container for the Flarum application.

View File

@@ -24,10 +24,10 @@ class ContainerUtil
public static function wrapCallback($callback, Container $container)
{
if (is_string($callback)) {
$callback = function () use ($container, $callback) {
$callback = function (&...$args) use ($container, $callback) {
$callback = $container->make($callback);
return call_user_func_array($callback, func_get_args());
return $callback(...$args);
};
}

View File

@@ -7,19 +7,13 @@
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Group;
namespace Flarum\Group\Access;
use Flarum\User\AbstractPolicy;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class GroupPolicy extends AbstractPolicy
{
/**
* {@inheritdoc}
*/
protected $model = Group::class;
/**
* @param User $actor
* @param string $ability
@@ -28,18 +22,7 @@ class GroupPolicy extends AbstractPolicy
public function can(User $actor, $ability)
{
if ($actor->hasPermission('group.'.$ability)) {
return true;
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
{
if ($actor->cannot('viewHiddenGroups')) {
$query->where('is_hidden', false);
return $this->allow();
}
}
}

View File

@@ -0,0 +1,27 @@
<?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\Group\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeGroupVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
if ($actor->cannot('viewHiddenGroups')) {
$query->where('is_hidden', false);
}
}
}

View File

@@ -10,6 +10,7 @@
namespace Flarum\Group;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Group\Access\ScopeGroupVisibility;
class GroupServiceProvider extends AbstractServiceProvider
{
@@ -18,7 +19,6 @@ class GroupServiceProvider extends AbstractServiceProvider
*/
public function boot()
{
$events = $this->app->make('events');
$events->subscribe(GroupPolicy::class);
Group::registerVisibilityScoper(new ScopeGroupVisibility(), 'view');
}
}

View File

@@ -9,7 +9,13 @@
namespace Flarum\Http;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\IdWithTransliteratedSlugDriver;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Flarum\User\UsernameSlugDriver;
use Illuminate\Support\Arr;
class HttpServiceProvider extends AbstractServiceProvider
{
@@ -25,5 +31,35 @@ class HttpServiceProvider extends AbstractServiceProvider
$this->app->bind(Middleware\CheckCsrfToken::class, function ($app) {
return new Middleware\CheckCsrfToken($app->make('flarum.http.csrfExemptPaths'));
});
$this->app->singleton('flarum.http.slugDrivers', function () {
return [
Discussion::class => [
'default' => IdWithTransliteratedSlugDriver::class
],
User::class => [
'default' => UsernameSlugDriver::class
],
];
});
$this->app->singleton('flarum.http.selectedSlugDrivers', function () {
$settings = $this->app->make(SettingsRepositoryInterface::class);
$compiledDrivers = [];
foreach ($this->app->make('flarum.http.slugDrivers') as $resourceClass => $resourceDrivers) {
$driverKey = $settings->get("slug_driver_$resourceClass", 'default');
$driverClass = Arr::get($resourceDrivers, $driverKey, $resourceDrivers['default']);
$compiledDrivers[$resourceClass] = $this->app->make($driverClass);
}
return $compiledDrivers;
});
$this->app->bind(SlugManager::class, function () {
return new SlugManager($this->app->make('flarum.http.selectedSlugDrivers'));
});
}
}

View File

@@ -0,0 +1,20 @@
<?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\Http;
use Flarum\Database\AbstractModel;
use Flarum\User\User;
interface SlugDriverInterface
{
public function toSlug(AbstractModel $instance): string;
public function fromSlug(string $slug, User $actor): AbstractModel;
}

27
src/Http/SlugManager.php Normal file
View File

@@ -0,0 +1,27 @@
<?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\Http;
use Illuminate\Support\Arr;
class SlugManager
{
protected $drivers = [];
public function __construct(array $drivers)
{
$this->drivers = $drivers;
}
public function forResource(string $resourceName): SlugDriverInterface
{
return Arr::get($this->drivers, $resourceName, null);
}
}

View File

@@ -41,7 +41,7 @@ class AlertNotificationDriver implements NotificationDriverInterface
*/
public function registerType(string $blueprintClass, array $driversEnabledByDefault): void
{
User::addPreference(
User::registerPreference(
User::getNotificationPreferenceKey($blueprintClass::getType(), 'alert'),
'boolval',
in_array('alert', $driversEnabledByDefault)

View File

@@ -59,7 +59,7 @@ class EmailNotificationDriver implements NotificationDriverInterface
public function registerType(string $blueprintClass, array $driversEnabledByDefault): void
{
if ((new ReflectionClass($blueprintClass))->implementsInterface(MailableInterface::class)) {
User::addPreference(
User::registerPreference(
User::getNotificationPreferenceKey($blueprintClass::getType(), 'email'),
'boolval',
in_array('email', $driversEnabledByDefault)

View File

@@ -11,7 +11,6 @@ namespace Flarum\Notification;
use Carbon\Carbon;
use Flarum\Database\AbstractModel;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Notification\Blueprint\BlueprintInterface;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
@@ -161,9 +160,9 @@ class Notification extends AbstractModel
->from((new $class)->getTable())
->whereColumn('id', 'subject_id');
static::$dispatcher->dispatch(
new ScopeModelVisibility($class::query()->setQuery($query), $actor, 'view')
);
if (method_exists($class, 'registerVisibilityScoper')) {
$class::query()->setQuery($query)->whereVisibleTo($actor);
}
});
});
}

View File

@@ -0,0 +1,76 @@
<?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\Post\Access;
use Carbon\Carbon;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class PostPolicy extends AbstractPolicy
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @param SettingsRepositoryInterface $settings
*/
public function __construct(SettingsRepositoryInterface $settings)
{
$this->settings = $settings;
}
/**
* @param User $actor
* @param string $ability
* @param \Flarum\Post\Post $post
* @return bool|null
*/
public function can(User $actor, $ability, Post $post)
{
if ($actor->can($ability.'Posts', $post->discussion)) {
return $this->allow();
}
}
/**
* @param User $actor
* @param Post $post
* @return bool|null
*/
public function edit(User $actor, Post $post)
{
// A post is allowed to be edited if the user is the author, the post
// hasn't been deleted by someone else, and the user is allowed to
// create new replies in the discussion.
if ($post->user_id == $actor->id && (! $post->hidden_at || $post->hidden_user_id == $actor->id) && $actor->can('reply', $post->discussion)) {
$allowEditing = $this->settings->get('allow_post_editing');
if ($allowEditing === '-1'
|| ($allowEditing === 'reply' && $post->number >= $post->discussion->last_post_number)
|| ($post->created_at->diffInMinutes(new Carbon) < $allowEditing)) {
return $this->allow();
}
}
}
/**
* @param User $actor
* @param Post $post
* @return bool|null
*/
public function hide(User $actor, Post $post)
{
return $this->edit($actor, $post);
}
}

View File

@@ -0,0 +1,62 @@
<?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\Post\Access;
use Flarum\Discussion\Discussion;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopePostVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, $query)
{
// Make sure the post's discussion is visible as well.
$query->whereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id');
Discussion::query()->setQuery($query)->whereVisibleTo($actor);
});
// Hide private posts by default.
$query->where(function ($query) use ($actor) {
$query->where('posts.is_private', false)
->orWhere(function ($query) use ($actor) {
$query->whereVisibleTo($actor, 'viewPrivate');
});
});
// Hide hidden posts, unless they are authored by the current user, or
// the current user has permission to view hidden posts in the
// discussion.
if (! $actor->hasPermission('discussion.hidePosts')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('posts.hidden_at')
->orWhere('posts.user_id', $actor->id)
->orWhereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id')
->where(function ($query) use ($actor) {
$query
->whereRaw('1=0')
->orWhere(function ($query) use ($actor) {
Discussion::query()->setQuery($query)->whereVisibleTo($actor, 'hidePosts');
});
});
});
});
}
}
}

View File

@@ -11,6 +11,9 @@ namespace Flarum\Post\Event;
use Flarum\User\User;
/**
* @deprecated beta 15, remove beta 16
*/
class CheckingForFlooding
{
/**

View File

@@ -1,143 +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\Post;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AbstractPolicy;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
class PostPolicy extends AbstractPolicy
{
/**
* {@inheritdoc}
*/
protected $model = Post::class;
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var Dispatcher
*/
protected $events;
/**
* @param SettingsRepositoryInterface $settings
* @param Dispatcher $events
*/
public function __construct(SettingsRepositoryInterface $settings, Dispatcher $events)
{
$this->settings = $settings;
$this->events = $events;
}
/**
* @param User $actor
* @param string $ability
* @param \Flarum\Post\Post $post
* @return bool|null
*/
public function can(User $actor, $ability, Post $post)
{
if ($actor->can($ability.'Posts', $post->discussion)) {
return true;
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, $query)
{
// Make sure the post's discussion is visible as well.
$query->whereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id');
$this->events->dispatch(
new ScopeModelVisibility(Discussion::query()->setQuery($query), $actor, 'view')
);
});
// Hide private posts by default.
$query->where(function ($query) use ($actor) {
$query->where('posts.is_private', false)
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility($query, $actor, 'viewPrivate')
);
});
});
// Hide hidden posts, unless they are authored by the current user, or
// the current user has permission to view hidden posts in the
// discussion.
if (! $actor->hasPermission('discussion.hidePosts')) {
$query->where(function ($query) use ($actor) {
$query->whereNull('posts.hidden_at')
->orWhere('posts.user_id', $actor->id)
->orWhereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('discussions')
->whereColumn('discussions.id', 'posts.discussion_id')
->where(function ($query) use ($actor) {
$query
->whereRaw('1=0')
->orWhere(function ($query) use ($actor) {
$this->events->dispatch(
new ScopeModelVisibility(Discussion::query()->setQuery($query), $actor, 'hidePosts')
);
});
});
});
});
}
}
/**
* @param User $actor
* @param Post $post
* @return bool|null
*/
public function edit(User $actor, Post $post)
{
// A post is allowed to be edited if the user is the author, the post
// hasn't been deleted by someone else, and the user is allowed to
// create new replies in the discussion.
if ($post->user_id == $actor->id && (! $post->hidden_at || $post->hidden_user_id == $actor->id) && $actor->can('reply', $post->discussion)) {
$allowEditing = $this->settings->get('allow_post_editing');
if ($allowEditing === '-1'
|| ($allowEditing === 'reply' && $post->number >= $post->discussion->last_post_number)
|| ($post->created_at->diffInMinutes(new Carbon) < $allowEditing)) {
return true;
}
}
}
/**
* @param User $actor
* @param Post $post
* @return bool|null
*/
public function hide(User $actor, Post $post)
{
return $this->edit($actor, $post);
}
}

View File

@@ -12,6 +12,7 @@ namespace Flarum\Post;
use DateTime;
use Flarum\Event\ConfigurePostTypes;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Post\Access\ScopePostVisibility;
class PostServiceProvider extends AbstractServiceProvider
{
@@ -50,8 +51,7 @@ class PostServiceProvider extends AbstractServiceProvider
$this->setPostTypes();
$events = $this->app->make('events');
$events->subscribe(PostPolicy::class);
Post::registerVisibilityScoper(new ScopePostVisibility(), 'view');
}
protected function setPostTypes()

View File

@@ -22,7 +22,6 @@ use Illuminate\Queue\Console as Commands;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Failed\NullFailedJobProvider;
use Illuminate\Queue\Listener as QueueListener;
use Illuminate\Queue\QueueManager;
use Illuminate\Queue\SyncQueue;
use Illuminate\Queue\Worker;
@@ -66,7 +65,7 @@ class QueueServiceProvider extends AbstractServiceProvider
$config = $app->make(Config::class);
return new Worker(
new QueueManager($app),
$app[Factory::class],
$app['events'],
$app[ExceptionHandling::class],
function () use ($config) {

View File

@@ -54,6 +54,7 @@ abstract class AbstractPolicy
/**
* @param ScopeModelVisibility $event
* @deprecated beta 15, remove beta 16
*/
public function scopeModelVisibility(ScopeModelVisibility $event)
{

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Access;
use Flarum\User\User;
abstract class AbstractPolicy
{
public const GLOBAL = 'GLOBAL';
public const ALLOW = 'ALLOW';
public const DENY = 'DENY';
public const FORCE_ALLOW = 'FORCE_ALLOW';
public const FORCE_DENY = 'FORCE_DENY';
protected function allow()
{
return static::ALLOW;
}
protected function deny()
{
return static::DENY;
}
protected function forceAllow()
{
return static::FORCE_ALLOW;
}
protected function forceDeny()
{
return static::FORCE_DENY;
}
/**
* @param User $user
* @param string $ability
* @param $instance
* @return bool|void
*/
public function checkAbility(User $actor, string $ability, $instance)
{ // If a specific method for this ability is defined,
// call that and return any non-null results
if (method_exists($this, $ability)) {
$result = call_user_func_array([$this, $ability], [$actor, $instance]);
if (! is_null($result)) {
return $result;
}
}
// If a "total access" method is defined, try that.
if (method_exists($this, 'can')) {
return call_user_func_array([$this, 'can'], [$actor, $ability, $instance]);
}
}
}

131
src/User/Access/Gate.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Access;
use Flarum\Database\AbstractModel;
use Flarum\Event\GetPermission;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
class Gate
{
protected const EVALUATION_CRITERIA_PRIORITY = [
AbstractPolicy::FORCE_DENY => false,
AbstractPolicy::FORCE_ALLOW => true,
AbstractPolicy::DENY => false,
AbstractPolicy::ALLOW => true,
];
/**
* @var Container
*/
protected $container;
/**
* @var Dispatcher
*/
protected $events;
/**
* @var array
*/
protected $policyClasses;
/**
* @var array
*/
protected $policies;
/**
* @param Dispatcher $events
*/
public function __construct(Container $container, Dispatcher $events, array $policyClasses)
{
$this->container = $container;
$this->events = $events;
$this->policyClasses = $policyClasses;
}
/**
* Determine if the given ability should be granted for the current user.
*
* @param User $actor
* @param string $ability
* @param string|AbstractModel $model
* @return bool
*/
public function allows(User $actor, string $ability, $model): bool
{
$results = [];
$appliedPolicies = [];
if ($model) {
$modelClasses = is_string($model) ? [$model] : array_merge(class_parents(($model)), [get_class($model)]);
foreach ($modelClasses as $class) {
$appliedPolicies = array_merge($appliedPolicies, $this->getPolicies($class));
}
} else {
$appliedPolicies = $this->getPolicies(AbstractPolicy::GLOBAL);
}
foreach ($appliedPolicies as $policy) {
$results[] = $policy->checkAbility($actor, $ability, $model);
}
foreach (static::EVALUATION_CRITERIA_PRIORITY as $criteria => $decision) {
if (in_array($criteria, $results, true)) {
return $decision;
}
}
// START OLD DEPRECATED SYSTEM
// Fire an event so that core and extension modelPolicies can hook into
// this permission query and explicitly grant or deny the
// permission.
$allowed = $this->events->until(
new GetPermission($actor, $ability, $model)
);
if (! is_null($allowed)) {
return $allowed;
}
// END OLD DEPRECATED SYSTEM
// If no policy covered this permission query, we will only grant
// the permission if the actor's groups have it. Otherwise, we will
// not allow the user to perform this action.
if ($actor->isAdmin() || ($actor->hasPermission($ability))) {
return true;
}
return false;
}
/**
* Get all policies for a given model and ability.
*/
protected function getPolicies(string $model)
{
$compiledPolicies = Arr::get($this->policies, $model);
if (is_null($compiledPolicies)) {
$policyClasses = Arr::get($this->policyClasses, $model, []);
$compiledPolicies = array_map(function ($policyClass) {
return $this->container->make($policyClass);
}, $policyClasses);
Arr::set($this->policies, $model, $compiledPolicies);
}
return $compiledPolicies;
}
}

View File

@@ -7,34 +7,18 @@
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User;
namespace Flarum\User\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class UserPolicy extends AbstractPolicy
class ScopeUserVisibility
{
/**
* {@inheritdoc}
*/
protected $model = User::class;
/**
* @param User $actor
* @param string $ability
* @return bool|null
*/
public function can(User $actor, $ability)
{
if ($actor->hasPermission('user.'.$ability)) {
return true;
}
}
/**
* @param User $actor
* @param Builder $query
*/
public function find(User $actor, Builder $query)
public function __invoke(User $actor, $query)
{
if ($actor->cannot('viewDiscussions')) {
if ($actor->isGuest()) {

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Access;
use Flarum\User\User;
class UserPolicy extends AbstractPolicy
{
/**
* @param User $actor
* @param string $ability
* @return bool|null
*/
public function can(User $actor, $ability)
{
if ($actor->hasPermission('user.'.$ability)) {
return $this->allow();
}
}
}

View File

@@ -80,6 +80,6 @@ class AvatarValidator extends AbstractValidator
protected function getAllowedTypes()
{
return ['jpeg', 'png', 'bmp', 'gif'];
return ['jpg', 'png', 'bmp', 'gif'];
}
}

View File

@@ -1,60 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User;
use Flarum\Event\GetPermission;
use Illuminate\Contracts\Events\Dispatcher;
class Gate
{
/**
* @var Dispatcher
*/
protected $events;
/**
* @param Dispatcher $events
*/
public function __construct(Dispatcher $events)
{
$this->events = $events;
}
/**
* Determine if the given ability should be granted for the current user.
*
* @param User $actor
* @param string $ability
* @param array|mixed $arguments
* @return bool
*/
public function allows($actor, $ability, $arguments)
{
// Fire an event so that core and extension policies can hook into
// this permission query and explicitly grant or deny the
// permission.
$allowed = $this->events->until(
new GetPermission($actor, $ability, $arguments)
);
if (! is_null($allowed)) {
return $allowed;
}
// If no policy covered this permission query, we will only grant
// the permission if the actor's groups have it. Otherwise, we will
// not allow the user to perform this action.
if ($actor->isAdmin() || ($actor->hasPermission($ability))) {
return true;
}
return false;
}
}

View File

@@ -143,6 +143,9 @@ class User extends AbstractModel
Notification::whereSubject($user)->delete();
});
/**
* @deprecated beta 15, remove beta 16
*/
static::$dispatcher->dispatch(
new ConfigureUserPreferences
);
@@ -621,7 +624,7 @@ class User extends AbstractModel
* @param mixed $arguments
* @throws PermissionDeniedException
*/
public function assertCan($ability, $arguments = [])
public function assertCan($ability, $arguments = null)
{
$this->assertPermission(
$this->can($ability, $arguments)
@@ -759,7 +762,7 @@ class User extends AbstractModel
* @param array|mixed $arguments
* @return bool
*/
public function can($ability, $arguments = [])
public function can($ability, $arguments = null)
{
return static::$gate->allows($this, $ability, $arguments);
}
@@ -769,7 +772,7 @@ class User extends AbstractModel
* @param array|mixed $arguments
* @return bool
*/
public function cannot($ability, $arguments = [])
public function cannot($ability, $arguments = null)
{
return ! $this->can($ability, $arguments);
}
@@ -801,6 +804,8 @@ class User extends AbstractModel
}
/**
* @deprecated beta 15, remove beta 16. Use `registerPreference` instead.
*
* Register a preference with a transformer and a default value.
*
* @param string $key
@@ -808,6 +813,18 @@ class User extends AbstractModel
* @param mixed $default
*/
public static function addPreference($key, callable $transformer = null, $default = null)
{
return static::registerPreference($key, $transformer, $default);
}
/**
* Register a preference with a transformer and a default value.
*
* @param string $key
* @param callable $transformer
* @param mixed $default
*/
public static function registerPreference($key, callable $transformer = null, $default = null)
{
static::$preferences[$key] = compact('transformer', 'default');
}

View File

@@ -40,6 +40,23 @@ class UserRepository
return $this->scopeVisibleTo($query, $actor)->firstOrFail();
}
/**
* Find a user by username, optionally making sure it is visible to a certain
* user, or throw an exception.
*
* @param int $id
* @param User $actor
* @return User
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function findOrFailByUsername($username, User $actor = null)
{
$query = User::where('username', $username);
return $this->scopeVisibleTo($query, $actor)->firstOrFail();
}
/**
* Find a user by an identification (username or email).
*

View File

@@ -9,10 +9,16 @@
namespace Flarum\User;
use Flarum\Event\ConfigureUserPreferences;
use Flarum\Discussion\Access\DiscussionPolicy;
use Flarum\Discussion\Discussion;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ContainerUtil;
use Flarum\Group\Access\GroupPolicy;
use Flarum\Group\Group;
use Flarum\Post\Access\PostPolicy;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Access\ScopeUserVisibility;
use Flarum\User\DisplayName\DriverInterface;
use Flarum\User\DisplayName\UsernameDriver;
use Flarum\User\Event\EmailChangeRequested;
@@ -36,6 +42,16 @@ class UserServiceProvider extends AbstractServiceProvider
$this->app->singleton('flarum.user.group_processors', function () {
return [];
});
$this->app->singleton('flarum.policies', function () {
return [
Access\AbstractPolicy::GLOBAL => [],
Discussion::class => [DiscussionPolicy::class],
Group::class => [GroupPolicy::class],
Post::class => [PostPolicy::class],
User::class => [Access\UserPolicy::class],
];
});
}
protected function registerDisplayNameDrivers()
@@ -81,29 +97,22 @@ class UserServiceProvider extends AbstractServiceProvider
User::addGroupProcessor(ContainerUtil::wrapCallback($callback, $this->app));
}
User::setHasher($this->app->make('hash'));
User::setGate($this->app->make(Gate::class));
User::setDisplayNameDriver($this->app->make('flarum.user.display_name.driver'));
$events = $this->app->make('events');
User::setHasher($this->app->make('hash'));
User::setGate($this->app->makeWith(Access\Gate::class, ['policyClasses' => $this->app->make('flarum.policies')]));
User::setDisplayNameDriver($this->app->make('flarum.user.display_name.driver'));
$events->listen(Saving::class, SelfDemotionGuard::class);
$events->listen(Registered::class, AccountActivationMailer::class);
$events->listen(EmailChangeRequested::class, EmailConfirmationMailer::class);
$events->subscribe(UserMetadataUpdater::class);
$events->subscribe(UserPolicy::class);
$events->listen(ConfigureUserPreferences::class, [$this, 'configureUserPreferences']);
}
User::registerPreference('discloseOnline', 'boolval', true);
User::registerPreference('indexProfile', 'boolval', true);
User::registerPreference('locale');
/**
* @param ConfigureUserPreferences $event
*/
public function configureUserPreferences(ConfigureUserPreferences $event)
{
$event->add('discloseOnline', 'boolval', true);
$event->add('indexProfile', 'boolval', true);
$event->add('locale');
User::registerVisibilityScoper(new ScopeUserVisibility(), 'view');
}
}

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\User;
use Flarum\Database\AbstractModel;
use Flarum\Http\SlugDriverInterface;
class UsernameSlugDriver implements SlugDriverInterface
{
/**
* @var UserRepository
*/
protected $users;
public function __construct(UserRepository $users)
{
$this->users = $users;
}
public function toSlug(AbstractModel $instance): string
{
return $instance->username;
}
public function fromSlug(string $slug, User $actor): AbstractModel
{
return $this->users->findOrFailByUsername($slug, $actor);
}
}

View File

@@ -66,6 +66,24 @@ class ShowTest extends TestCase
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function author_can_see_discussion_via_slug()
{
// Note that here, the slug doesn't actually have to match the real slug
// since the default slugging strategy only takes the numerical part into account
$response = $this->send(
$this->request('GET', '/api/discussions/1-fdsafdsajfsakf', [
'authenticatedAs' => 2,
])->withQueryParams([
'bySlug' => true
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/

View File

@@ -25,9 +25,10 @@ class CreateTest extends TestCase
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser(),
],
'groups' => [
$this->adminGroup(),
$this->adminGroup()
],
'group_user' => [
['user_id' => 1, 'group_id' => 1],

View File

@@ -0,0 +1,197 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\users;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
class ShowTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser(),
],
'groups' => [
$this->adminGroup()
],
'group_user' => [
['user_id' => 1, 'group_id' => 1],
],
'settings' => [
['key' => 'mail_driver', 'value' => 'log'],
],
]);
}
/**
* @test
*/
public function admin_can_see_user()
{
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function admin_can_see_user_via_slug()
{
$response = $this->send(
$this->request('GET', '/api/users/normal', [
'authenticatedAs' => 1,
])->withQueryParams([
'bySlug' => true
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function guest_cannot_see_user()
{
$response = $this->send(
$this->request('GET', '/api/users/2')
);
$this->assertEquals(404, $response->getStatusCode());
}
/**
* @test
*/
public function guest_cannot_see_user_by_slug()
{
$response = $this->send(
$this->request('GET', '/api/users/2')->withQueryParams([
'bySlug' => true
])
);
$this->assertEquals(404, $response->getStatusCode());
}
/**
* @test
*/
public function user_can_see_themselves()
{
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function user_can_see_themselves_via_slug()
{
$response = $this->send(
$this->request('GET', '/api/users/normal', [
'authenticatedAs' => 2,
])->withQueryParams([
'bySlug' => true
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function user_cant_see_others_by_default()
{
$response = $this->send(
$this->request('GET', '/api/users/1', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(404, $response->getStatusCode());
}
/**
* @test
*/
public function user_cant_see_others_by_default_via_slug()
{
$response = $this->send(
$this->request('GET', '/api/users/admin', [
'authenticatedAs' => 2,
])->withQueryParams([
'bySlug' => true
])
);
$this->assertEquals(404, $response->getStatusCode());
}
/**
* @test
*/
public function user_can_see_others_if_allowed()
{
$this->prepareDatabase([
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 3],
]
]);
$response = $this->send(
$this->request('GET', '/api/users/1', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function user_can_see_others_if_allowed_via_slug()
{
$this->prepareDatabase([
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 3],
]
]);
$response = $this->send(
$this->request('GET', '/api/users/admin', [
'authenticatedAs' => 2,
])->withQueryParams([
'bySlug' => true
])
);
$this->assertEquals(200, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,773 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Api\Controller\AbstractShowController;
use Flarum\Api\Controller\ListDiscussionsController;
use Flarum\Api\Controller\ShowDiscussionController;
use Flarum\Api\Controller\ShowForumController;
use Flarum\Api\Controller\ShowPostController;
use Flarum\Api\Controller\ShowUserController;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class ApiControllerTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser()
],
'groups' => [
$this->adminGroup(),
$this->memberGroup()
],
'discussions' => [
['id' => 1, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
['id' => 2, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 3, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
['id' => 3, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
],
]);
}
/**
* @test
*/
public function prepare_data_serialization_callback_works_if_added()
{
$this->extend(
(new Extend\ApiController(ShowDiscussionController::class))
->prepareDataForSerialization(function ($controller, Discussion $discussion) {
$discussion->title = 'dataSerializationPrepCustomTitle';
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertEquals('dataSerializationPrepCustomTitle', $payload['data']['attributes']['title']);
}
/**
* @test
*/
public function prepare_data_serialization_callback_works_with_invokable_classes()
{
$this->extend(
(new Extend\ApiController(ShowDiscussionController::class))
->prepareDataForSerialization(CustomPrepareDataSerializationInvokableClass::class)
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertEquals(CustomPrepareDataSerializationInvokableClass::class, $payload['data']['attributes']['title']);
}
/**
* @test
*/
public function prepare_data_serialization_allows_passing_args_by_reference_with_closures()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->hasMany('referenceTest', UserSerializer::class),
(new Extend\ApiController(ShowForumController::class))
->addInclude('referenceTest')
->prepareDataForSerialization(function ($controller, &$data) {
$data['referenceTest'] = User::limit(2)->get();
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('referenceTest', $payload['data']['relationships']);
}
/**
* @test
*/
public function prepare_data_serialization_allows_passing_args_by_reference_with_invokable_classes()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->hasMany('referenceTest2', UserSerializer::class),
(new Extend\ApiController(ShowForumController::class))
->addInclude('referenceTest2')
->prepareDataForSerialization(CustomInvokableClassArgsReference::class)
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('referenceTest2', $payload['data']['relationships']);
}
/**
* @test
*/
public function prepare_data_serialization_callback_works_if_added_to_parent_class()
{
$this->extend(
(new Extend\ApiController(AbstractShowController::class))
->prepareDataForSerialization(function ($controller, Discussion $discussion) {
if ($controller instanceof ShowDiscussionController) {
$discussion->title = 'dataSerializationPrepCustomTitle2';
}
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertEquals('dataSerializationPrepCustomTitle2', $payload['data']['attributes']['title']);
}
/**
* @test
*/
public function prepare_data_serialization_callback_prioritizes_child_classes()
{
$this->extend(
(new Extend\ApiController(AbstractShowController::class))
->prepareDataForSerialization(function ($controller, Discussion $discussion) {
if ($controller instanceof ShowDiscussionController) {
$discussion->title = 'dataSerializationPrepCustomTitle3';
}
}),
(new Extend\ApiController(ShowDiscussionController::class))
->prepareDataForSerialization(function ($controller, Discussion $discussion) {
$discussion->title = 'dataSerializationPrepCustomTitle4';
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertEquals('dataSerializationPrepCustomTitle4', $payload['data']['attributes']['title']);
}
/**
* @test
*/
public function prepare_data_query_callback_works_if_added_to_parent_class()
{
$this->extend(
(new Extend\ApiController(AbstractShowController::class))
->prepareDataQuery(function ($controller) {
if ($controller instanceof ShowDiscussionController) {
$controller->setSerializer(CustomDiscussionSerializer2::class);
}
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSerializer2', $payload['data']['attributes']);
}
/**
* @test
*/
public function prepare_data_query_callback_prioritizes_child_classes()
{
$this->extend(
(new Extend\ApiController(AbstractShowController::class))
->prepareDataForSerialization(function ($controller) {
if ($controller instanceof ShowDiscussionController) {
$controller->setSerializer(CustomDiscussionSerializer2::class);
}
}),
(new Extend\ApiController(ShowDiscussionController::class))
->prepareDataForSerialization(function ($controller) {
$controller->setSerializer(CustomDiscussionSerializer::class);
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSerializer', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_serializer_doesnt_work_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('customSerializer', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_serializer_works_if_set()
{
$this->extend(
(new Extend\ApiController(ShowDiscussionController::class))
->setSerializer(CustomDiscussionSerializer::class)
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSerializer', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_serializer_works_if_set_with_invokable_class()
{
$this->extend(
(new Extend\ApiController(ShowPostController::class))
->setSerializer(CustomPostSerializer::class, CustomApiControllerInvokableClass::class)
);
$this->prepDb();
$this->prepareDatabase([
'posts' => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>'],
],
]);
$response = $this->send(
$this->request('GET', '/api/posts/1', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSerializer', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_serializer_doesnt_work_with_false_callback_return()
{
$this->extend(
(new Extend\ApiController(ShowUserController::class))
->setSerializer(CustomUserSerializer::class, function () {
return false;
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('customSerializer', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_relationship_not_included_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('customApiControllerRelation', $payload['data']['relationships']);
$this->assertArrayNotHasKey('customApiControllerRelation2', $payload['data']['relationships']);
}
/**
* @test
*/
public function custom_relationship_included_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('customApiControllerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasMany('customApiControllerRelation', DiscussionSerializer::class),
(new Extend\ApiController(ShowUserController::class))
->addInclude('customApiControllerRelation')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customApiControllerRelation', $payload['data']['relationships']);
}
/**
* @test
*/
public function custom_relationship_optionally_included_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('customApiControllerRelation2', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasMany('customApiControllerRelation2', DiscussionSerializer::class),
(new Extend\ApiController(ShowUserController::class))
->addOptionalInclude('customApiControllerRelation2')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])->withQueryParams([
'include' => 'customApiControllerRelation2',
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customApiControllerRelation2', $payload['data']['relationships']);
}
/**
* @test
*/
public function custom_relationship_included_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('groups', $payload['data']['relationships']);
}
/**
* @test
*/
public function custom_relationship_not_included_if_removed()
{
$this->extend(
(new Extend\ApiController(ShowUserController::class))
->removeInclude('groups')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('groups', Arr::get($payload, 'data.relationships', []));
}
/**
* @test
*/
public function custom_relationship_not_optionally_included_if_removed()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('customApiControllerRelation2', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasMany('customApiControllerRelation2', DiscussionSerializer::class),
(new Extend\ApiController(ShowUserController::class))
->addOptionalInclude('customApiControllerRelation2')
->removeOptionalInclude('customApiControllerRelation2')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])->withQueryParams([
'include' => 'customApiControllerRelation2',
])
);
$this->assertEquals(400, $response->getStatusCode());
}
/**
* @test
*/
public function custom_limit_doesnt_work_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertCount(3, $payload['data']);
}
/**
* @test
*/
public function custom_limit_works_if_set()
{
$this->extend(
(new Extend\ApiController(ListDiscussionsController::class))
->setLimit(1)
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertCount(1, $payload['data']);
}
/**
* @test
*/
public function custom_max_limit_works_if_set()
{
$this->extend(
(new Extend\ApiController(ListDiscussionsController::class))
->setMaxLimit(1)
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])->withQueryParams([
'page' => ['limit' => '5'],
])
);
$payload = json_decode($response->getBody(), true);
$this->assertCount(1, $payload['data']);
}
/**
* @test
*/
public function custom_sort_field_doesnt_exist_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])->withQueryParams([
'sort' => 'userId',
])
);
$this->assertEquals(400, $response->getStatusCode());
}
/**
* @test
*/
public function custom_sort_field_doesnt_work_with_false_callback_return()
{
$this->extend(
(new Extend\ApiController(ListDiscussionsController::class))
->addSortField('userId', function () {
return false;
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])->withQueryParams([
'sort' => 'userId',
])
);
$this->assertEquals(400, $response->getStatusCode());
}
/**
* @test
*/
public function custom_sort_field_exists_if_added()
{
$this->extend(
(new Extend\ApiController(ListDiscussionsController::class))
->addSortField('userId')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])->withQueryParams([
'sort' => 'userId',
])
);
$payload = json_decode($response->getBody(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals([3, 1, 2], Arr::pluck($payload['data'], 'id'));
}
/**
* @test
*/
public function custom_sort_field_exists_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])->withQueryParams([
'sort' => 'createdAt',
])
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function custom_sort_field_doesnt_exist_if_removed()
{
$this->extend(
(new Extend\ApiController(ListDiscussionsController::class))
->removeSortField('createdAt')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])->withQueryParams([
'sort' => 'createdAt',
])
);
$this->assertEquals(400, $response->getStatusCode());
}
/**
* @test
*/
public function custom_sort_field_works_if_set()
{
$this->extend(
(new Extend\ApiController(ListDiscussionsController::class))
->addSortField('userId')
->setSort(['userId' => 'desc'])
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api/discussions', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals([2, 1, 3], Arr::pluck($payload['data'], 'id'));
}
}
class CustomDiscussionSerializer extends DiscussionSerializer
{
protected function getDefaultAttributes($discussion)
{
return parent::getDefaultAttributes($discussion) + [
'customSerializer' => true
];
}
}
class CustomDiscussionSerializer2 extends DiscussionSerializer
{
protected function getDefaultAttributes($discussion)
{
return parent::getDefaultAttributes($discussion) + [
'customSerializer2' => true
];
}
}
class CustomUserSerializer extends UserSerializer
{
protected function getDefaultAttributes($user)
{
return parent::getDefaultAttributes($user) + [
'customSerializer' => true
];
}
}
class CustomPostSerializer extends PostSerializer
{
protected function getDefaultAttributes($post)
{
return parent::getDefaultAttributes($post) + [
'customSerializer' => true
];
}
}
class CustomApiControllerInvokableClass
{
public function __invoke()
{
return true;
}
}
class CustomPrepareDataSerializationInvokableClass
{
public function __invoke(ShowDiscussionController $controller, Discussion $discussion)
{
$discussion->title = __CLASS__;
}
}
class CustomInvokableClassArgsReference
{
public function __invoke($controller, &$data)
{
$data['referenceTest2'] = User::limit(2)->get();
}
}

View File

@@ -45,15 +45,6 @@ class ApiSerializerTest extends TestCase
]);
}
protected function prepSettingsDb()
{
$this->prepareDatabase([
'settings' => [
['key' => 'customPrefix.customSetting', 'value' => 'customValue']
],
]);
}
/**
* @test
*/

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Flarum\Database\AbstractModel;
use Flarum\Extend;
use Flarum\Http\SlugDriverInterface;
use Flarum\Http\SlugManager;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
class ModelUrlTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$userClass = User::class;
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser(),
],
'settings' => [
['key' => "slug_driver_$userClass", 'value' => 'testDriver'],
]
]);
}
/**
* @test
*/
public function uses_default_driver_by_default()
{
$this->prepDb();
$slugManager = $this->app()->getContainer()->make(SlugManager::class);
$testUser = User::find(1);
$this->assertEquals('admin', $slugManager->forResource(User::class)->toSlug($testUser));
$this->assertEquals('1', $slugManager->forResource(User::class)->fromSlug('admin', $testUser)->id);
}
/**
* @test
*/
public function custom_slug_driver_has_effect_if_added()
{
$this->extend((new Extend\ModelUrl(User::class))->addSlugDriver('testDriver', TestSlugDriver::class));
$this->prepDb();
$slugManager = $this->app()->getContainer()->make(SlugManager::class);
$testUser = User::find(1);
$this->assertEquals('test-slug', $slugManager->forResource(User::class)->toSlug($testUser));
$this->assertEquals('1', $slugManager->forResource(User::class)->fromSlug('random-gibberish', $testUser)->id);
}
}
class TestSlugDriver implements SlugDriverInterface
{
public function toSlug(AbstractModel $instance): string
{
return 'test-slug';
}
public function fromSlug(string $slug, User $actor): AbstractModel
{
return User::find(1);
}
}

View File

@@ -0,0 +1,189 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ModelVisibilityTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$this->prepareDatabase([
'discussions' => [
['id' => 1, 'title' => 'Empty discussion', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => null, 'comment_count' => 0, 'is_private' => 0],
['id' => 2, 'title' => 'Discussion with post', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1, 'is_private' => 0],
['id' => 3, 'title' => 'Private discussion', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'is_private' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>a normal reply - too-obscure</p></t>'],
['id' => 2, 'discussion_id' => 3, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>private!</p></t>'],
],
'users' => [
$this->normalUser(),
],
'groups' => [
$this->guestGroup(),
$this->memberGroup(),
],
'group_user' => [
['user_id' => 2, 'group_id' => 3],
],
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 2],
['permission' => 'viewDiscussions', 'group_id' => 3],
]
]);
}
/**
* @test
*/
public function user_can_see_posts_by_default()
{
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(1, $visiblePosts);
}
/**
* @test
*/
public function custom_visibility_scoper_can_stop_user_from_seeing_posts()
{
$this->extend(
(new Extend\ModelVisibility(CommentPost::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=0');
}, 'view')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(0, $visiblePosts);
}
/**
* @test
*/
public function custom_visibility_scoper_applies_if_added_to_parent_class()
{
$this->extend(
(new Extend\ModelVisibility(Post::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=0');
}, 'view')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(0, $visiblePosts);
}
/**
* @test
*/
public function custom_visibility_scoper_for_class_applied_after_scopers_for_parent_class()
{
$this->extend(
(new Extend\ModelVisibility(CommentPost::class))
->scope(function (User $user, Builder $query) {
$query->orWhereRaw('1=1');
}, 'view'),
(new Extend\ModelVisibility(Post::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=0');
}, 'view')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(2, $visiblePosts);
}
/**
* @test
*/
public function custom_scoper_works_for_abilities_other_than_view()
{
$this->extend(
(new Extend\ModelVisibility(Discussion::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=1');
}, 'viewPrivate'),
(new Extend\ModelVisibility(Post::class))
->scope(function (User $user, Builder $query) {
$query->whereRaw('1=1');
}, 'viewPrivate')
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(2, $visiblePosts);
}
/**
* @test
*/
public function universal_scoper_works()
{
$this->extend(
(new Extend\ModelVisibility(Discussion::class))
->scopeAll(function (User $user, Builder $query, string $ability) {
if ($ability == 'viewPrivate') {
$query->whereRaw('1=1');
}
}),
(new Extend\ModelVisibility(Post::class))
->scopeAll(function (User $user, Builder $query, string $ability) {
if ($ability == 'viewPrivate') {
$query->whereRaw('1=1');
}
})
);
$this->prepDb();
$actor = User::find(2);
$visiblePosts = CommentPost::query()->whereVisibleTo($actor)->get();
$this->assertCount(2, $visiblePosts);
}
}

View File

@@ -0,0 +1,287 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Flarum\Tests\integration\BuildsHttpRequests;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class PolicyTest extends TestCase
{
use BuildsHttpRequests;
use RetrievesAuthorizedUsers;
// Request body to hide discussions sent in tests.
protected $hideQuery = ['authenticatedAs' => 2, 'json' => ['data' => ['attributes' => ['isHidden' => true]]]];
private function prepDb()
{
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser(),
],
'discussions' => [
['id' => 1, 'title' => 'Unrelated Discussion', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_private' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>a normal reply - too-obscure</p></t>'],
]
]);
}
/**
* @test
*/
public function unrelated_user_cant_hide_discussion_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('PATCH', '/api/discussions/1', $this->hideQuery)
);
$this->assertEquals(403, $response->getStatusCode());
}
/**
* @test
*/
public function unrelated_user_can_hide_discussion_if_allowed()
{
$this->extend(
(new Extend\Policy())
->modelPolicy(Discussion::class, CustomPolicy::class)
);
$this->prepDb();
$response = $this->send(
$this->request('PATCH', '/api/discussions/1', $this->hideQuery)
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function unrelated_user_cant_hide_discussion_if_denied()
{
$this->extend(
(new Extend\Policy())
->modelPolicy(Discussion::class, DenyHidePolicy::class)
->modelPolicy(Discussion::class, CustomPolicy::class)
);
$this->prepDb();
$response = $this->send(
$this->request('PATCH', '/api/discussions/1', $this->hideQuery)
);
$this->assertEquals(403, $response->getStatusCode());
}
/**
* @test
*/
public function unrelated_user_can_hide_discussion_if_force_allowed()
{
$this->extend(
(new Extend\Policy())
->modelPolicy(Discussion::class, ForceAllowHidePolicy::class)
->modelPolicy(Discussion::class, DenyHidePolicy::class)
->modelPolicy(Discussion::class, CustomPolicy::class)
);
$this->prepDb();
$response = $this->send(
$this->request('PATCH', '/api/discussions/1', $this->hideQuery)
);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function unrelated_user_cant_hide_discussion_if_force_denied()
{
$this->extend(
(new Extend\Policy())
->modelPolicy(Discussion::class, DenyHidePolicy::class)
->modelPolicy(Discussion::class, ForceDenyHidePolicy::class)
->modelPolicy(Discussion::class, CustomPolicy::class)
->modelPolicy(Discussion::class, ForceAllowHidePolicy::class)
);
$this->prepDb();
$response = $this->send(
$this->request('PATCH', '/api/discussions/1', $this->hideQuery)
);
$this->assertEquals(403, $response->getStatusCode());
}
/**
* @test
*/
public function regular_user_cant_start_discussions_by_default()
{
$this->prepDb();
$user = User::find(2);
$this->assertEquals(false, $user->can('startDiscussion'));
}
/**
* @test
*/
public function regular_user_can_start_discussions_if_granted_by_global_policy()
{
$this->extend(
(new Extend\Policy)
->globalPolicy(GlobalStartDiscussionPolicy::class)
);
$this->prepDb();
$user = User::find(2);
$this->assertEquals(true, $user->can('startDiscussion'));
}
/**
* @test
*/
public function global_policy_doesnt_apply_if_argument_provided()
{
$this->extend(
(new Extend\Policy)
->globalPolicy(GlobalStartDiscussionPolicy::class)
);
$this->prepDb();
$user = User::find(2);
$this->assertEquals(false, $user->can('startDiscussion', Discussion::find(1)));
}
/**
* @test
*/
public function unrelated_user_cant_hide_post_by_default()
{
$this->prepDb();
$user = User::find(2);
$this->assertEquals(false, $user->can('hide', Post::find(1)));
}
/**
* @test
*/
public function unrelated_user_can_hide_post_if_allowed()
{
$this->extend(
(new Extend\Policy)->modelPolicy(CommentPost::class, CommentPostChildClassPolicy::class)
);
$this->prepDb();
$user = User::find(2);
$this->assertEquals(true, $user->can('hide', Post::find(1)));
}
/**
* @test
*/
public function policies_are_inherited_to_child_classes()
{
$this->extend(
(new Extend\Policy)->modelPolicy(Post::class, PostParentClassPolicy::class),
(new Extend\Policy)->modelPolicy(CommentPost::class, CommentPostChildClassPolicy::class)
);
$this->prepDb();
$user = User::find(2);
$this->assertEquals(false, $user->can('hide', Post::find(1)));
}
}
class CustomPolicy extends AbstractPolicy
{
protected function hide(User $user, Discussion $discussion)
{
return $this->allow();
}
}
class DenyHidePolicy extends AbstractPolicy
{
protected function hide(User $user, Discussion $discussion)
{
return $this->deny();
}
}
class ForceAllowHidePolicy extends AbstractPolicy
{
protected function hide(User $user, Discussion $discussion)
{
return $this->forceAllow();
}
}
class ForceDenyHidePolicy extends AbstractPolicy
{
protected function hide(User $user, Discussion $discussion)
{
return $this->forceDeny();
}
}
class GlobalStartDiscussionPolicy extends AbstractPolicy
{
protected function startDiscussion(User $user)
{
return $this->allow();
}
}
class PostParentClassPolicy extends AbstractPolicy
{
protected function hide(User $user, Post $post)
{
return $this->deny();
}
}
class CommentPostChildClassPolicy extends AbstractPolicy
{
protected function hide(User $user, CommentPost $post)
{
return $this->allow();
}
}

View File

@@ -0,0 +1,133 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Flarum\Extend;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
class SettingsTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser()
],
'settings' => [
['key' => 'custom-prefix.custom_setting', 'value' => 'customValue'],
['key' => 'custom-prefix.custom_setting2', 'value' => 'customValue']
]
]);
}
/**
* @test
*/
public function custom_setting_isnt_serialized_by_default()
{
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('customPrefix.customSetting', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_setting_serialized_if_added()
{
$this->extend(
(new Extend\Settings())
->serializeToForum('customPrefix.customSetting', 'custom-prefix.custom_setting')
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customPrefix.customSetting', $payload['data']['attributes']);
$this->assertEquals('customValue', $payload['data']['attributes']['customPrefix.customSetting']);
}
/**
* @test
*/
public function custom_setting_callback_works_if_added()
{
$this->extend(
(new Extend\Settings())
->serializeToForum('customPrefix.customSetting', 'custom-prefix.custom_setting', function ($value) {
return $value.'Modified';
})
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customPrefix.customSetting', $payload['data']['attributes']);
$this->assertEquals('customValueModified', $payload['data']['attributes']['customPrefix.customSetting']);
}
/**
* @test
*/
public function custom_setting_callback_works_with_invokable_class()
{
$this->extend(
(new Extend\Settings())
->serializeToForum('customPrefix.customSetting2', 'custom-prefix.custom_setting2', CustomInvokableClass::class)
);
$this->prepDb();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customPrefix.customSetting2', $payload['data']['attributes']);
$this->assertEquals('customValueModifiedByInvokable', $payload['data']['attributes']['customPrefix.customSetting2']);
}
}
class CustomInvokableClass
{
public function __invoke($value)
{
return $value.'ModifiedByInvokable';
}
}

View File

@@ -85,6 +85,8 @@ class ThrottleApiTest extends TestCase
$this->prepDb();
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(200, $response->getStatusCode());

View File

@@ -14,6 +14,7 @@ use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\DisplayName\DriverInterface;
use Flarum\User\User;
use Illuminate\Support\Arr;
class UserTest extends TestCase
{
@@ -32,9 +33,20 @@ class UserTest extends TestCase
'settings' => [
['key' => 'display_name_driver', 'value' => 'custom'],
],
'group_permission' => [
['permission' => 'viewUserList', 'group_id' => 3],
]
]);
}
protected function registerTestPreference()
{
$this->extend(
(new Extend\User())
->registerPreference('test', 'boolval', true)
);
}
/**
* @test
*/
@@ -104,6 +116,51 @@ class UserTest extends TestCase
$this->assertNotContains('viewUserList', $user->getPermissions());
}
/**
* @test
*/
public function can_add_user_preference()
{
$this->registerTestPreference();
$this->prepDb();
/** @var User $user */
$user = User::find(2);
$this->assertEquals(true, Arr::get($user->preferences, 'test'));
}
/**
* @test
*/
public function can_store_user_preference()
{
$this->registerTestPreference();
$this->prepDb();
/** @var User $user */
$user = User::find(2);
$user->setPreference('test', false);
$this->assertEquals(false, $user->getPreference('test'));
}
/**
* @test
*/
public function storing_user_preference_modified_by_transformer()
{
$this->registerTestPreference();
$this->prepDb();
/** @var User $user */
$user = User::find(2);
$user->setPreference('test', []);
$this->assertEquals(false, $user->getPreference('test'));
}
}
class CustomDisplayNameDriver implements DriverInterface

View File

@@ -0,0 +1,140 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\unit\Foundation;
use Flarum\Foundation\ContainerUtil;
use Flarum\Tests\unit\TestCase;
use Illuminate\Container\Container;
class ContainerUtilTest extends TestCase
{
private $container;
protected function setUp()
{
parent::setUp();
$this->container = new Container();
}
/** @test */
public function it_works_with_closures()
{
$callback = ContainerUtil::wrapCallback(function ($array) {
$array['key'] = 'newValue';
return 'return';
}, $this->container);
$array = ['key' => 'value'];
$return = $callback($array);
$this->assertEquals('value', $array['key']);
$this->assertEquals('return', $return);
}
/** @test */
public function it_works_with_invokable_classes()
{
$callback = ContainerUtil::wrapCallback(CustomInvokableClass::class, $this->container);
$array = ['key' => 'value2'];
$return = $callback($array);
$this->assertEquals('value2', $array['key']);
$this->assertEquals('return2', $return);
}
/** @test */
public function it_works_with_invokable_objects()
{
$callback = ContainerUtil::wrapCallback(new class {
public function __invoke($array)
{
$array['key'] = 'newValue5';
return 'return5';
}
}, $this->container);
$array = ['key' => 'value5'];
$return = $callback($array);
$this->assertEquals('value5', $array['key']);
$this->assertEquals('return5', $return);
}
/** @test */
public function it_allows_passing_args_by_reference_on_closures()
{
$callback = ContainerUtil::wrapCallback(function (&$array) {
$array['key'] = 'newValue3';
return 'return3';
}, $this->container);
$array = ['key' => 'value3'];
$return = $callback($array);
$this->assertEquals('newValue3', $array['key']);
$this->assertEquals('return3', $return);
}
/** @test */
public function it_allows_passing_args_by_reference_on_invokable_classes()
{
$callback = ContainerUtil::wrapCallback(SecondCustomInvokableClass::class, $this->container);
$array = ['key' => 'value4'];
$return = $callback($array);
$this->assertEquals('newValue4', $array['key']);
$this->assertEquals('return4', $return);
}
/** @test */
public function it_allows_passing_args_by_reference_on_invokable_objects()
{
$callback = ContainerUtil::wrapCallback(new class {
public function __invoke(&$array)
{
$array['key'] = 'newValue6';
return 'return6';
}
}, $this->container);
$array = ['key' => 'value6'];
$return = $callback($array);
$this->assertEquals('newValue6', $array['key']);
$this->assertEquals('return6', $return);
}
}
class CustomInvokableClass
{
public function __invoke($array)
{
$array['key'] = 'newValue2';
return 'return2';
}
}
class SecondCustomInvokableClass
{
public function __invoke(&$array)
{
$array['key'] = 'newValue4';
return 'return4';
}
}

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