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

Compare commits

...

60 Commits

Author SHA1 Message Date
David Wheatley
6a57183525 fix: update usage of sort map 2021-10-27 20:08:55 +02:00
David Wheatley
2c801711bb fix: use ItemList get method 2021-10-27 18:41:53 +02:00
David Wheatley
69b1dc7103 feat!: use ItemList for the DiscussionListState sorting map 2021-10-27 17:16:03 +02:00
Alexander Skvortsov
6200ffef9b Hide webkit search button (#3128) 2021-10-27 09:28:40 -04:00
flarum-bot
5e84490fd0 Bundled output for commit 2b0d55632e
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-26 21:35:14 +00:00
Alexander Skvortsov
2b0d55632e ExtensionPage: rename "Uninstall" to "Purge" (#3123)
https://i.imgur.com/aOOkqhk.png
2021-10-26 17:32:39 -04:00
Alexander Skvortsov
f7a78d85e3 Pass IP address to API Client pipeline (#3124)
The `ProcessIp` middleware won't run twice as that's in the global middleware stack, which the API client doesn't go through.
2021-10-26 17:11:40 -04:00
Sami Mazouz
972411673f fix: Use laravel validator to replace avatar validation error params (#2946) 2021-10-26 14:45:27 +01:00
flarum-bot
7ebf535b25 Bundled output for commit a661376d16
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-25 21:37:01 +00:00
Alexander Skvortsov
a661376d16 Catch errors when uploading white avatar (#3119) 2021-10-25 17:34:39 -04:00
MatusMak
5a1bf08d3f #2492 - Groups filtering & retrieve single endpoint (#3084)
Fixes #2492

* Added api/groups/{id} endpoint for retrieving a single group by its id
* Fixed GroupRepository incorrectly opening query to User instead of Group model
* Added filtering & paging abilities to GET api/groups endpoint
* Added test for sorting for GET api/groups endpoint

Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
2021-10-25 11:48:25 -04:00
flarum-bot
a9b1a518a2 Bundled output for commit 9416b1c150
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-25 05:47:17 +00:00
Clark Winkelmann
9416b1c150 Fix mail settings select component never being used (#3120) 2021-10-25 01:44:46 -04:00
Alexander Skvortsov
87f67744a8 Throw error if required route params missing (#3118)
Co-authored-by: Daniël Klabbers <daniel@klabbers.email>
Co-authored-by: luceos <luceos@users.noreply.github.com>
Co-authored-by: David Wheatley <hi@davwheat.dev>
Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
2021-10-23 14:05:47 -04:00
SychO9
4add23a984 chore: Update version constant to 1.2.0-dev 2021-10-18 21:04:07 +01:00
flarum-bot
c52c0987fb Bundled output for commit 60f0ef0bd5
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-14 18:33:00 +00:00
Alexander Skvortsov
60f0ef0bd5 Handle post rendering errors to avoid bricking (#3061)
Whether it's due to corrupted content, missing tags, caching issues, or other assorted reasons, post content can't be rendered. Currently, this results in an exception that crashes the entire forum and is hard to debug. Instead, we should log the error and show an indicator message that rendering has failed.

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: David Wheatley <hi@davwheat.dev>
2021-10-14 14:30:18 -04:00
flarum-bot
82d67919bb Bundled output for commit 713d95eb36
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-14 13:43:57 +00:00
Sami Mazouz
713d95eb36 fix: import app from common app instead (#3104)
Introduced in #3099
2021-10-14 14:41:22 +01:00
flarum-bot
d053bb5496 Bundled output for commit 05121b928a
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-13 18:58:05 +00:00
David Sevilla Martin
05121b928a Lazy draw dropdowns to improve performance (#2925) 2021-10-13 14:55:32 -04:00
Fransiscus Rolanda Malau
0a7e885eab Add missing autocomplete attributes to input fields (#3088)
* Add missing autocomplete attributes to input fields
* Add autocomplete attributes to password fields
* Attribute should use new-password
2021-10-13 14:53:35 -04:00
Maarten Bicknese
a65488000c Disallow dashes in database prefix (#3089)
As a temporary fix it has been requested to disallow dashes in the database prefix. The installation process fails when the prefix does include a dash.

#3022
2021-10-13 14:52:53 -04:00
Wouter
4146a4c578 Added new translations for the user editing modal (#3093) 2021-10-13 14:52:17 -04:00
flarum-bot
3f2e25b35f Bundled output for commit 2a86c25297
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-13 18:51:06 +00:00
Braunson Yager
2a86c25297 Added ES6 local support for formatNumber helper as per #2951 (#3099) 2021-10-13 14:48:37 -04:00
Sergiy Petrov
919c543cbc Test against php 8.1 (#3102) 2021-10-13 14:48:03 -04:00
Alexander Skvortsov
99112429f9 Release v1.1.0 2021-10-11 21:19:05 -04:00
Daniël Klabbers
b4772e5399 [huntr] adding cache control headers to the admin area (#3097)
This PR forces the `Cache-Control: no-store, max-age=0` header to the response in the Admin Area. This forces cache to be ignored upon browsing back and forth between pages using the browser controls. Although absolutely no fail safe, it should provide better protection against serving cached pages once an admin has signed out.
2021-10-07 18:34:22 -04:00
flarum-bot
2b47e90827 Bundled output for commit 1c2465b2da
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-10-07 03:33:54 +00:00
Alexander Skvortsov
1c2465b2da Support filter params in discussion list state
https://github.com/flarum/core/pull/3068 accidentially broke the user discussions page, as up until this commit, `DiscussionListState`didn't accept any filter params.
2021-10-06 23:30:32 -04:00
Alexander Skvortsov
a6717ee981 Remove .html on all docs urls
Now that Flarum docs have been moved to docusaurus, URLs no longer end with `.html`.

Closes https://github.com/flarum/core/issues/3092
2021-10-05 10:13:19 -04:00
flarum-bot
450ab61620 Bundled output for commit e2f01c040b
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-09-29 15:32:51 +00:00
Sami Mazouz
e2f01c040b fix: Anchors should not have type="button" (#3086) 2021-09-29 11:30:31 -04:00
Sami Mazouz
1d15cff9ca Filter composer icon array to only valid values (#3080) 2021-09-25 18:35:27 +01:00
David Wheatley
88724bb4cb performance(frontend): Preload FontAwesome, JS and CSS (#3057)
* Add preloads support to Document class

* Add frontend extender for asset preloading

* Provide default preloads for FontAwesome

* Add tests for preload extender and default preloads

* Apply fixes from StyleCI

[ci skip] [skip ci]

* Fix typo

* Fix two more typos 🙃

* Preload core JS and CSS

* Apply fixes from StyleCI

[ci skip] [skip ci]

* Reorder preloads

* Remove singular preloads method

* Use filesystem disk driver for getting FA font paths

* Update test to use full URL

* Apply fixes from StyleCI

[ci skip] [skip ci]

* Address review comment

* Apply fixes from StyleCI

[ci skip] [skip ci]

* Fix typo

* Apply fixes from StyleCI

[ci skip] [skip ci]

* Correct callback wrapping

* Update src/Extend/Frontend.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update src/Extend/Frontend.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update src/Extend/Frontend.php

* Fix preload extender logic

* Convert base FontAwesome preloads into a Singleton

* Apply fixes from StyleCI

[ci skip] [skip ci]

Co-authored-by: luceos <luceos@users.noreply.github.com>
Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
2021-09-20 23:12:09 +01:00
Alexander Skvortsov
1637b90531 Add determinsm to extension order resolution (#3076)
By sorting alphabetically by extension ID before applying topological sort, we ensure that a given set of extensions will always be booted in the same order. This will make it easier to replicate issues caused by complex extension dependencies.
2021-09-20 11:40:00 -04:00
flarum-bot
245d0d2550 Bundled output for commit 5dd48e1b86
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-09-20 15:08:49 +00:00
David Wheatley
5dd48e1b86 [A11Y] Accessibility improvements for the Search component (#3017)
* Remove deprecated code

* Accessibility improvements for Search component
2021-09-20 16:06:15 +01:00
Sami Mazouz
c1a8c6c190 fix: Sanitise integer query parameters (#3064) 2021-09-17 20:50:11 +01:00
David Wheatley
c10a30bae9 [A11Y] Adds missing focus rings back to control elements (#3016)
* Remove the stuff that removes critical accessibility features

* Remove no outline from basic blade layout

* Remove focus outline from FormControls
2021-09-13 23:47:13 +01:00
flarum-bot
b0bc021034 Bundled output for commit 1b193196da
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-09-11 08:26:35 +00:00
Rafael Horvat
1b193196da Use author filter parameter instead of q with gambit to get a user's discussions on the DiscussionsUserPage (#3068) 2021-09-11 09:24:15 +01:00
Sami Mazouz
f56fc11af9 [1.x] Theme Extender to Allow overriding LESS files (#3008)
This PR introduces the ability to just override a LESS file's contents through an extender.
This is mainly useful for theme development, as there are times in extensively customized themes where overriding the actual file makes a huge difference vs overriding CSS styles which can turn into a maintenance hell real fast.

Overriding styles is more tedious than overriding files. When you're designing an element, you would normally rather start from a blank canvas, than a styled element. With an already styled element you have to first override and undo the styles you do not wish to have, only then can you start shaping it, but even then you'd always end up constantly undoing default styles. This mostly applies for more advanced themes. (example: 851c55516d/less/forum/DiscussionList.less)
2021-09-10 13:45:18 -04:00
flarum-bot
ebdc232b11 Bundled output for commit eb0dd1f0d0
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-09-06 00:46:37 +00:00
David Sevilla Martín
eb0dd1f0d0 Add extra error handling for avatar file size & large payload (#3042)
* Add extra error handling for avatar file size & large payload

* Change error message to return 'upload failure' on most errors instead of 'no file' message
2021-09-05 20:43:59 -04:00
Sami Mazouz
1aa61f1f01 fix: Unable to use permission grid dropdowns due to z-index mistake (#3065)
The one I had suggested here: https://github.com/flarum/core/pull/2887#pullrequestreview-690047538
2021-09-05 18:29:18 +01:00
Sami Mazouz
e8153ccc79 feat: NoJs Admin View (#3059)
Adds a nojs blade template to be able to enable/disable extensions when one of them misbehaves.
2021-08-31 09:08:27 +01:00
Alexander Skvortsov
55d8af44a2 Move SECURITY.md file to central org repo 2021-08-30 15:43:52 -04:00
Alexander Skvortsov
4de5ad94f0 Use central FUNDING file 2021-08-30 15:42:07 -04:00
Alexander Skvortsov
735583397c Move PR template to central repo 2021-08-30 15:41:18 -04:00
Alexander Skvortsov
da94488f7b Update lastSeenAt when authenticating via API (#3058)
Fixes https://github.com/flarum/core/issues/3025, title says it all.
2021-08-27 14:02:03 -04:00
Ian Morland
581d9517db Pass filter params to getApiDocument (#3037)
* Pass filter params to getApiDocument

* Set filters directly
2021-08-26 10:47:34 +01:00
flarum-bot
3db724e0b3 Bundled output for commit 71073b064a
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-08-26 09:35:55 +00:00
Rafael Horvat
71073b064a Allow adding page parameters to PaginatedListState, like limit. (#2935) 2021-08-26 10:33:22 +01:00
flarum-bot
d82c093c0f Bundled output for commit c2a0cf8d04
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2021-08-25 17:35:41 +00:00
Sami Mazouz
c2a0cf8d04 fix: Extension admin page erroring out (#3054)
Extension admin pages are currently not working because of a JS error.
The settings record is never defined but directly used, it used to be defined as an empty object in oninit.
2021-08-25 13:33:19 -04:00
SychO9
1b77df12b6 Merge remote-tracking branch 'upstream/1.0.5' 2021-08-25 17:00:45 +01:00
Sami Mazouz
d333d0b0e6 perf: Allow eager loading posts relations of GET discussion endpoint (#3048) 2021-08-23 20:33:21 +01:00
Sami Mazouz
b5620e0549 Throw a validation error on ico favicons. (#2949) 2021-08-21 16:14:33 +01:00
99 changed files with 2437 additions and 1240 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
github: flarum
open_collective: flarum

View File

@@ -1,24 +0,0 @@
<!--
IMPORTANT: We applaud pull requests, they excite us every single time. As we have an obligation to maintain a healthy code standard and quality, we take sufficient time for reviews. Please do create a separate pull request per change/issue/feature; we will ask you to split bundled pull requests.
-->
**Fixes #0000**
**Changes proposed in this pull request:**
<!-- fill this out, mention the pages and/or components which have been impacted -->
**Reviewers should focus on:**
<!-- fill this out, ask for feedback on specific changes you are unsure about -->
**Screenshot**
<!-- include an image of the most relevant user-facing change, if any -->
**Confirmed**
- [ ] Frontend changes: tested on a local Flarum installation.
- [ ] Backend changes: tests are green (run `composer test`).
**Required changes:**
- [ ] Related documentation PR: (Remove if irrelevant)
- [ ] Related core extension PRs: (Remove if irrelevant)

13
.github/SECURITY.md vendored
View File

@@ -1,13 +0,0 @@
# Security Policy
## Versions
Due to the nature of our project - being open source - we have decided to patch only the latest major release (currently v1.x) for security vulnerabilities.
## How to disclose
Please use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via [this form](https://huntr.dev/bounties/disclose/?target=https://github.com/flarum/core).
This will enable us to **review** the vulnerability, **fix** it promptly, and **reward** you for your efforts.
If you have any questions about the process, feel free to reach out to security@huntr.dev or security@flarum.org.

View File

@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
php: [7.3, 7.4, '8.0']
php: [7.3, 7.4, '8.0', '8.1']
service: ['mysql:5.7', mariadb]
prefix: ['', flarum_]

View File

@@ -1,5 +1,60 @@
# Changelog
## [1.1.0](https://github.com/flarum/core/compare/v1.0.4...v1.1.0)
### Added
- Info command now displays MySQL version, queue driver, mail driver (https://github.com/flarum/core/pull/2991)
- Use organization Prettier config (https://github.com/flarum/core/pull/2967)
- Support for global typings in extensions (https://github.com/flarum/core/pull/2992)
- Typings for class component state attribute (https://github.com/flarum/core/pull/2995)
- Custom colorising with CSS custom properties (https://github.com/flarum/core/pull/3001)
- Theme Extender to allow overriding LESS files (https://github.com/flarum/core/pull/3008)
- Update lastSeenAt when authenticating via API (https://github.com/flarum/core/pull/3058)
- NoJs Admin View (https://github.com/flarum/core/pull/3059)
- Preload FontAwesome, JS and CSS, and add `preload` extender (https://github.com/flarum/core/pull/3057)
### Changed
- Move Day.js plugin types import to global typings (https://github.com/flarum/core/pull/2954)
- Avoid resolving excluded middleware on each middleware items
- Allow extra attrs provided to `<Select>` to be passed through to the DOM element (https://github.com/flarum/core/pull/2959)
- Limit height of code blocks (https://github.com/flarum/core/pull/3012)
- Update normalize.css from v3.0.2 to v8.0.1 (https://github.com/flarum/core/pull/3015)
- Permission Grid: stick the headers to handle a lot of tags (https://github.com/flarum/core/pull/2887)
- Use `ItemList` for `DiscussionPage` content (https://github.com/flarum/core/pull/3004)
- Move email confirmation to POST request (https://github.com/flarum/core/pull/3038)
- Minor CSS code cleanup (https://github.com/flarum/core/pull/3026)
- Replace username with display name in more places (https://github.com/flarum/core/pull/3040)
- Rewrite Button to Typescript (https://github.com/flarum/core/pull/2984)
- Rewrite AdminPage abstract component into Typescript (https://github.com/flarum/core/pull/2996)
- Allow adding page parameters to PaginatedListState (https://github.com/flarum/core/pull/2935)
- Pass filter params to getApiDocument (https://github.com/flarum/core/pull/3037)
- Use author filter instead of gambit to get a user's discussions (https://github.com/flarum/core/pull/3068)
- [A11Y] Accessibility improvements for the Search component (https://github.com/flarum/core/pull/3017)
- Add determinsm to extension order resolution (https://github.com/flarum/core/pull/3076)
- Add cache control headers to the admin area (https://github.com/flarum/core/pull/3097)
### Fixed
- HLJS 11 new styles resulting in double padding (https://github.com/flarum/core/pull/2909)
- Internal API client attempting to load an uninstantiated session
- Empty post footer taking visual space (https://github.com/flarum/core/pull/2926)
- Unrecognized component class custom attribute typings (https://github.com/flarum/core/pull/2962)
- User edit groups permission not visually depending on view hidden groups permission (https://github.com/flarum/core/pull/2880)
- Event post excerpt preview triggers error (https://github.com/flarum/core/pull/2964)
- Missing settings defaults for display name driver and User slug driver (https://github.com/flarum/core/pull/2971)
- [A11Y] Icons not hidden from screenreaders (https://github.com/flarum/core/pull/3027)
- [A11Y] Checkboxes not focusable (https://github.com/flarum/core/pull/3014)
- Uploading ICO favicons resulting in server errors (https://github.com/flarum/core/pull/2949)
- Missing proper validation for large avatar upload payload (https://github.com/flarum/core/pull/3042)
- [A11Y] Missing focus rings in control elements (https://github.com/flarum/core/pull/3016)
- Unsanitised integer query parameters (https://github.com/flarum/core/pull/3064)
###### Code Contributors
@lhsazevedo, @Ornanovitch, @pierres, @the-turk, @iPurpl3x
###### Issue Reporters
@uamv, @dannyuk1982, @BurnNoticeSpy, @haarp, @peopleinside, @matteocontrini
## [1.0.4](https://github.com/flarum/core/compare/v1.0.3...v1.0.4)
### Fixed

View File

@@ -1,9 +1,9 @@
/**
* The `formatNumber` utility localizes a number into a string with the
* appropriate punctuation.
* appropriate punctuation based on the provided locale otherwise will default to the users locale.
*
* @example
* formatNumber(1234);
* // 1,234
*/
export default function formatNumber(number: number): string;
export default function formatNumber(number: number, locale?: string): string;

4
js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

2
js/dist/admin.js.map generated vendored

File diff suppressed because one or more lines are too long

4
js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

2
js/dist/forum.js.map generated vendored

File diff suppressed because one or more lines are too long

1478
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
"prettier": "^2.3.0",
"typescript": "^4.2.4",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-cli": "^4.9.0",
"webpack-merge": "^4.2.2"
},
"scripts": {

View File

@@ -106,7 +106,7 @@ export type SettingsComponentOptions = HTMLInputSettingsComponentOptions | Switc
export type AdminHeaderAttrs = AdminHeaderOptions & Partial<Omit<Mithril.Attributes, 'class'>>;
export default abstract class AdminPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends Page<CustomAttrs> {
settings!: Record<string, Stream<string>>;
settings: Record<string, Stream<string>> = {};
loading: boolean = false;
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {

View File

@@ -141,8 +141,8 @@ export default class ExtensionPage extends AdminPage {
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
if (!this.isEnabled()) {
const uninstall = () => {
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
const purge = () => {
if (confirm(app.translator.trans('core.admin.extension.confirm_purge'))) {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
@@ -154,10 +154,11 @@ export default class ExtensionPage extends AdminPage {
}
};
// TODO v2.0: rename `uninstall` to `purge`
items.add(
'uninstall',
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
{app.translator.trans('core.admin.extension.uninstall_button')}
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={purge.bind(this)}>
{app.translator.trans('core.admin.extension.purge_button')}
</Button>
);
}

View File

@@ -23,7 +23,7 @@ export default class HeaderSecondary extends Component {
items.add(
'help',
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
<LinkButton href="https://docs.flarum.org/troubleshoot/" icon="fas fa-question-circle" external={true} target="_blank">
{app.translator.trans('core.admin.header.get_help')}
</LinkButton>
);

View File

@@ -79,7 +79,7 @@ export default class MailPage extends AdminPage {
return [
this.buildSettingComponent({
type: typeof this.setting(field)() === 'string' ? 'text' : 'select',
type: typeof fieldInfo === 'string' ? 'text' : 'select',
label: app.translator.trans(`core.admin.email.${field}_label`),
setting: field,
options: fieldInfo,

View File

@@ -38,6 +38,7 @@ export default class PermissionDropdown extends Dropdown {
attrs.className = 'PermissionDropdown';
attrs.buttonClassName = 'Button Button--text';
attrs.lazyDraw = true;
}
view(vnode) {

View File

@@ -144,6 +144,7 @@ export default class PermissionGrid extends Component {
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
],
lazyDraw: true,
}),
},
90
@@ -191,6 +192,7 @@ export default class PermissionGrid extends Component {
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
],
lazyDraw: true,
});
},
},

View File

@@ -382,6 +382,10 @@ export default class Application {
content = app.translator.trans('core.lib.error.not_found_message');
break;
case 413:
content = app.translator.trans('core.lib.error.payload_too_large_message');
break;
case 429:
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;

View File

@@ -38,11 +38,12 @@ export default class Dropdown extends Component {
view(vnode) {
const items = vnode.children ? listItems(vnode.children) : [];
const renderItems = this.attrs.lazyDraw ? this.showing : true;
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton(vnode.children)}
{this.getMenu(items)}
{renderItems && this.getMenu(items)}
</div>
);
}
@@ -54,13 +55,25 @@ export default class Dropdown extends Component {
// bottom of the viewport. If it does, we will apply class to make it show
// above the toggle button instead of below it.
this.$().on('shown.bs.dropdown', () => {
const { lazyDraw, onshow } = this.attrs;
this.showing = true;
if (this.attrs.onshow) {
this.attrs.onshow();
// If using lazy drawing, redraw before calling `onshow` function
// to make sure the menu DOM exists in case the callback tries to use it.
if (lazyDraw) {
m.redraw.sync();
}
m.redraw();
if (typeof onshow === 'function') {
onshow();
}
// If not using lazy drawing, keep previous functionality
// of redrawing after calling onshow()
if (!lazyDraw) {
m.redraw();
}
const $menu = this.$('.Dropdown-menu');
const isRight = $menu.hasClass('Dropdown-menu--right');

View File

@@ -27,6 +27,7 @@ export default class LinkButton extends Button {
vdom.tag = Link;
vdom.attrs.active = String(vdom.attrs.active);
delete vdom.attrs.type;
return vdom;
}

View File

@@ -10,9 +10,11 @@ Object.assign(Post.prototype, {
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
contentHtml: Model.attribute('contentHtml'),
renderFailed: Model.attribute('renderFailed'),
contentPlain: computed('contentHtml', getPlainContent),
editedAt: Model.attribute('editedAt', Model.transformDate),

View File

@@ -89,8 +89,18 @@ Object.assign(User.prototype, {
const user = this;
image.onload = function () {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
try {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
} catch (e) {
// Completely white avatars throw errors due to a glitch in color thief
// See https://github.com/lokesh/color-thief/issues/40
if (e instanceof TypeError) {
user.avatarColor = [255, 255, 255];
} else {
throw e;
}
}
user.freshness = new Date();
m.redraw();
};

View File

@@ -92,7 +92,10 @@ export default abstract class PaginatedListState<T extends Model> {
*/
protected loadPage(page = 1): Promise<T[]> {
const params = this.requestParams();
params.page = { offset: this.pageSize * (page - 1) };
params.page = {
offset: this.pageSize * (page - 1),
...params.page,
};
if (Array.isArray(params.include)) {
params.include = params.include.join(',');

View File

@@ -1,11 +1,13 @@
import app from '../../common/app';
/**
* The `formatNumber` utility localizes a number into a string with the
* appropriate punctuation.
* appropriate punctuation based on the provided locale otherwise will default to the users locale.
*
* @example
* formatNumber(1234);
* // 1,234
*/
export default function formatNumber(number: number): string {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
export default function formatNumber(number: number, locale: string = app.data.locale): string {
return new Intl.NumberFormat(locale).format(number);
}

View File

@@ -77,6 +77,7 @@ export default class ChangeEmailModal extends Modal {
type="password"
name="password"
className="FormControl"
autocomplete="current-password"
placeholder={app.translator.trans('core.forum.change_email.confirm_password_placeholder')}
bidi={this.password}
disabled={this.loading}

View File

@@ -100,6 +100,7 @@ export default class CommentPost extends Post {
' ' +
classList({
CommentPost: true,
'Post--renderFailed': post.renderFailed(),
'Post--hidden': post.isHidden(),
'Post--edited': post.isEdited(),
revealContent: this.revealContent,

View File

@@ -17,7 +17,7 @@ export default class DiscussionsUserPage extends UserPage {
super.show(user);
this.state = new DiscussionListState({
q: 'author:' + user.username(),
filter: { author: user.username() },
sort: 'newest',
});

View File

@@ -217,34 +217,33 @@ export default class IndexPage extends Page {
*/
viewItems() {
const items = new ItemList();
const sortMap = app.discussions.sortMap();
const sortOptions = {};
for (const i in sortMap) {
sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button');
}
const sortOptions = Object.values(app.discussions.sortMap().toObject());
// Chooses the sort option with highest priority for now
const defaultSortMethod = sortOptions.reduce((acc, option) => (acc.priority > option.priority ? acc : option), { priority: -9e10 });
// Find the selected search method, otherwise heed the default
const activeSearchMethod = sortOptions.find((opt) => opt.itemName === app.search.params().sort) || defaultSortMethod;
items.add(
'sort',
Dropdown.component(
{
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
label: app.translator.trans(`core.forum.index_sort.${activeSearchMethod.itemName}_button`),
accessibleToggleLabel: app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label'),
},
Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component(
sortOptions.map(({ itemName: sortingId, content: sortType }) =>
Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
onclick: app.search.changeSort.bind(app.search, sortType),
active: active,
},
label
);
})
app.translator.trans(`core.forum.index_sort.${sortingId}_button`)
)
)
)
);

View File

@@ -83,6 +83,7 @@ export default class LogInModal extends Modal {
className="FormControl"
name="password"
type="password"
autocomplete="current-password"
placeholder={extractText(app.translator.trans('core.forum.log_in.password_placeholder'))}
bidi={this.password}
disabled={this.loading}

View File

@@ -103,14 +103,18 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
const isActive = !!currentSearch;
const shouldShowResults = !!(!this.loadingSources && this.state.getValue() && this.hasFocus);
const shouldShowClearButton = !!(!this.loadingSources && this.state.getValue());
return (
<div
role="search"
className={classList({
Search: true,
aria-label={app.translator.trans('core.forum.header.search_role_label')}
className={classList('Search', {
open: this.state.getValue() && this.hasFocus,
focused: this.hasFocus,
active: !!currentSearch,
active: isActive,
loading: !!this.loadingSources,
})}
>
@@ -125,18 +129,23 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)}
/>
{this.loadingSources ? (
<LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />
) : currentSearch ? (
<button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>
{!!this.loadingSources && <LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />}
{shouldShowClearButton && (
<button
className="Search-clear Button Button--icon Button--link"
onclick={this.clear.bind(this)}
aria-label={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
>
{icon('fas fa-times-circle')}
</button>
) : (
''
)}
</div>
<ul className="Dropdown-menu Search-results">
{this.state.getValue() && this.hasFocus ? this.sources.map((source) => source.view(this.state.getValue())) : ''}
<ul
className="Dropdown-menu Search-results"
aria-hidden={!shouldShowResults || undefined}
aria-live={shouldShowResults ? 'polite' : undefined}
>
{shouldShowResults && this.sources.map((source) => source.view(this.state.getValue()))}
</ul>
</div>
);
@@ -174,7 +183,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
this.$('.Search-results')
.on('mousedown', (e) => e.preventDefault())
.on('click', () => this.$('input').blur())
.on('click', () => this.$('input').trigger('blur'))
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function () {
@@ -223,7 +232,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
.on('focus', function () {
$(this)
.one('mouseup', (e) => e.preventDefault())
.select();
.trigger('select');
});
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);

View File

@@ -104,6 +104,7 @@ export default class SignUpModal extends Modal {
className="FormControl"
name="password"
type="password"
autocomplete="new-password"
placeholder={extractText(app.translator.trans('core.forum.sign_up.password_placeholder'))}
bidi={this.password}
disabled={this.loading}

View File

@@ -1,6 +1,7 @@
import app from '../../forum/app';
import PaginatedListState, { Page } from '../../common/states/PaginatedListState';
import Discussion from '../../common/models/Discussion';
import ItemList from '../../common/utils/ItemList';
export default class DiscussionListState extends PaginatedListState<Discussion> {
protected extraDiscussions: Discussion[] = [];
@@ -14,9 +15,9 @@ export default class DiscussionListState extends PaginatedListState<Discussion>
}
requestParams() {
const params: any = { include: ['user', 'lastPostedUser'], filter: {} };
const params: any = { include: ['user', 'lastPostedUser'], filter: this.params.filter || {} };
params.sort = this.sortMap()[this.params.sort];
params.sort = this.sortMap().get(this.params.sort);
if (this.params.q) {
params.filter.q = this.params.q;
@@ -45,21 +46,22 @@ export default class DiscussionListState extends PaginatedListState<Discussion>
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* Get a list of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*/
sortMap() {
const map: any = {};
sortMap(): ItemList<string> {
const sortItems = new ItemList<string>();
if (this.params.q) {
map.relevance = '';
sortItems.add('relevance', '', 100);
}
map.latest = '-lastPostedAt';
map.top = '-commentCount';
map.newest = '-createdAt';
map.oldest = 'createdAt';
return map;
sortItems.add('latest', '-lastPostedAt', 80);
sortItems.add('top', '-commentCount', 60);
sortItems.add('newest', '-createdAt', 40);
sortItems.add('oldest', 'createdAt', 20);
return sortItems;
}
/**

View File

@@ -10,4 +10,5 @@
@import "admin/ExtensionWidget";
@import "admin/AppearancePage";
@import "admin/MailPage";
@import "admin/NoJs";
@import "admin/UsersListPage.less";

17
less/admin/NoJs.less Normal file
View File

@@ -0,0 +1,17 @@
// Minimal NoJs specific styles
.NoJs-ExtensionsTable {
td&-icon {
padding-top: 0;
padding-bottom: 0;
}
.ExtensionListItem-Dot {
position: relative;
right: 0;
margin: 0;
}
.ExtensionIcon {
--size: 25px;
}
}

View File

@@ -78,7 +78,7 @@
position: sticky;
left: 0;
padding-right: 50px;
z-index: 2;
z-index: 4;
background: inherit;
.icon {
@@ -143,7 +143,6 @@
td, th {
position: relative;
z-index: 0;
}
th {
font-weight: normal;

View File

@@ -64,12 +64,6 @@
&.active,
.open > &.Dropdown-toggle {
.box-shadow(inset 0 3px 5px rgba(0, 0, 0, .125));
outline: none;
}
&:focus,
&.focus {
outline: none;
}
&.disabled,

View File

@@ -63,9 +63,6 @@
&:hover {
background: @control-bg;
}
&:focus {
outline: none;
}
}
&.active {
> a, > button {

View File

@@ -13,8 +13,7 @@
transition: var(--transition);
-webkit-appearance: none;
&:focus,
&.focus {
&:focus {
background-color: @body-bg;
color: @text-color;
border-color: @primary-color;
@@ -44,6 +43,7 @@
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
}
}
.helpText {
font-size: 12px;
line-height: 1.5em;

View File

@@ -1,5 +1,22 @@
.Search {
position: relative;
// TODO v2.0 check if this is supported by Firefox,
// if so, consider switching to it.
::-webkit-search-cancel-button {
display: none;
}
&-clear {
// It looks very weird due to the padding given to the button..
&:focus {
outline: none;
}
// ...so we display the ring around the icon inside the button, with an offset
.add-keyboard-focus-ring-nearby("> *");
.add-keyboard-focus-ring-nearby-offset("> *", 4px);
}
}
@media @tablet-up {
.Search {
@@ -70,7 +87,6 @@
.Button {
float: left;
margin-left: -36px;
outline: none;
width: 36px !important;
&.LoadingIndicator {

69
less/common/Table.less Normal file
View File

@@ -0,0 +1,69 @@
.Table {
background: @control-bg;
border-radius: @border-radius;
border-collapse: collapse;
border-spacing: 0;
caption {
text-align: start;
}
td, th {
border-bottom: 1px solid @body-bg;
color: @control-color;
}
td, th, .Checkbox, &-controls-item {
padding: 10px 15px;
}
& &-checkbox, & &-controls {
padding: 0;
}
thead {
th {
text-align: center;
padding: 15px 25px;
}
.icon {
display: block;
font-size: 14px;
width: auto;
margin-bottom: 5px;
}
}
&-groupToggle {
cursor: pointer;
.icon {
font-size: 14px;
margin-right: 2px;
.fa-fw();
}
}
&-checkbox {
.Checkbox {
display: block;
}
.Checkbox-display {
text-align: center;
cursor: pointer;
}
&.highlighted .Checkbox, .Checkbox:hover {
&:not(.disabled) {
background: darken(@control-bg, 4%);
}
}
}
&-controls-item {
width: 100%;
border-radius: 0;
}
}

View File

@@ -27,6 +27,7 @@
@import "Placeholder";
@import "Search";
@import "Select";
@import "Table";
@import "TextEditor";
@import "Tooltip";
@import "ValidationError";

View File

@@ -130,3 +130,39 @@
.offset();
}
}
/**
* This mixin allows support for a custom element nearby the focused one
* to have a focus style applied to it
*
* For example...
*
*? button { .add-keyboard-focus-ring-nearby("+ .myOtherElement") }
* becomes
*? button:-moz-focusring + .myOtherElement { <styles> }
*? button:focus-within + .myOtherElement { <styles> }
*/
.add-keyboard-focus-ring-nearby-offset(@nearbySelector, @offset) {
@realNearbySelector: ~"@{nearbySelector}";
.offset() {
outline-offset: @offset;
}
// We need to declare these separately, otherwise
// browsers will ignore `:focus-visible` as they
// don't understand `:-moz-focusring`
// These are the keyboard-only versions of :focus
&:-moz-focusring {
@{realNearbySelector} {
.offset();
}
}
&:focus-visible {
@{realNearbySelector} {
.offset();
}
}
}

View File

@@ -1,52 +1,3 @@
.NotificationGrid {
background: @control-bg;
border-radius: @border-radius;
border-collapse: collapse;
border-spacing: 0;
td, th {
border-bottom: 1px solid @body-bg;
color: @control-color;
}
td, th, .Checkbox {
padding: 10px 15px;
}
.NotificationGrid-checkbox {
padding: 0;
}
thead {
th {
text-align: center;
padding: 15px 25px;
}
.icon {
display: block;
font-size: 14px;
width: auto;
margin-bottom: 5px;
}
}
}
.NotificationGrid-groupToggle {
cursor: pointer;
.icon {
font-size: 14px;
margin-right: 2px;
.fa-fw();
}
}
.NotificationGrid-checkbox {
.Checkbox {
display: block;
}
.Checkbox-display {
text-align: center;
cursor: pointer;
}
&.highlighted .Checkbox, .Checkbox:hover {
&:not(.disabled) {
background: darken(@control-bg, 4%);
}
}
.Table();
}

View File

@@ -185,6 +185,10 @@
}
}
.Post--renderFailed {
background-color: @control-danger-bg;
}
.Post--hidden {
.Post-header, .Post-header a, .PostUser h3, .PostUser h3 a {
color: @muted-more-color;

View File

@@ -115,7 +115,7 @@ core:
# These translations are used on default extension pages.
extension:
configure_scopes: Configure Scopes
confirm_uninstall: Uninstalling will remove all database entries and assets related to the extension. Are you sure you want to continue?
confirm_purge: Purging will remove all database entries and assets related to the extension. It will not uninstall the extension; that must be done via Composer. Are you sure you want to continue?
disabled: Disabled
enable_to_see: Enable the extension to view and change settings.
enabled: Enabled
@@ -130,7 +130,7 @@ core:
no_settings: This extension has no settings.
open_modal: Open Settings
permissions_title: Permissions
uninstall_button: Uninstall
purge_button: Purge
# These translations are used in the secondary header.
header:
@@ -348,7 +348,9 @@ core:
log_in_link: => core.ref.log_in
log_out_button: => core.ref.log_out
profile_button: Profile
search_clear_button_accessible_label: Clear search query
search_placeholder: Search Forum
search_role_label: Search Forum
session_dropdown_accessible_label: Toggle session options dropdown menu
settings_button: => core.ref.settings
sign_up_link: => core.ref.sign_up
@@ -514,6 +516,7 @@ core:
title: => core.ref.edit_user
username_heading: => core.ref.username
username_label: => core.ref.username
nothing_available: You are not allowed to edit this user.
# These translations are displayed as error messages.
error:
@@ -521,8 +524,10 @@ core:
generic_message: "Oops! Something went wrong. Please reload the page and try again."
missing_dependencies_message: "Cannot enable {extension} until the following dependencies are enabled: {extensions}"
not_found_message: The requested resource was not found.
payload_too_large_message: The request payload was too large.
permission_denied_message: You do not have permission to do that.
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
render_failed_message: Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.
# These translations are used in the loading indicator component.
loading_indicator:
@@ -545,6 +550,20 @@ core:
# Translations in this namespace are used in views other than Flarum's normal JS client.
views:
# Translations in this namespace are displayed by the basic HTML admin index.
admin:
extensions:
caption: => core.ref.extensions
disable: Disable
empty: No installed extensions
enable: Enable
name: Extension Name
package_name: Package Name
version: Version
info:
caption: Application Info
title: Administration
# Translations in this namespace are displayed by the Confirm Email interface.
confirm_email:
submit_button: => core.ref.confirm_email
@@ -673,6 +692,7 @@ core:
edit: Edit
edit_user: Edit User
email: Email
extensions: Extensions
icon: Icon
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
load_more: Load More

View File

@@ -28,6 +28,8 @@ validation:
ends_with: "The :attribute must end with one of the following: :values."
exists: "The selected :attribute is invalid."
file: "The :attribute must be a file."
file_too_large: "The :attribute is too large."
file_upload_failed: "The :attribute failed to upload."
filled: "The :attribute field must have a value."
gt:
numeric: "The :attribute must be greater than :value."

View File

@@ -61,7 +61,8 @@ class AdminServiceProvider extends AbstractServiceProvider
HttpMiddleware\CheckCsrfToken::class,
Middleware\RequireAdministrateAbility::class,
HttpMiddleware\ReferrerPolicyHeader::class,
HttpMiddleware\ContentTypeOptionsHeader::class
HttpMiddleware\ContentTypeOptionsHeader::class,
Middleware\DisableBrowserCache::class,
];
});

View File

@@ -0,0 +1,60 @@
<?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\Admin\Content;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\Application;
use Flarum\Frontend\Document;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\View\Factory;
use Psr\Http\Message\ServerRequestInterface as Request;
class Index
{
/**
* @var Factory
*/
protected $view;
/**
* @var ExtensionManager
*/
protected $extensions;
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
public function __construct(Factory $view, ExtensionManager $extensions, SettingsRepositoryInterface $settings)
{
$this->view = $view;
$this->extensions = $extensions;
$this->settings = $settings;
}
public function __invoke(Document $document, Request $request): Document
{
$extensions = $this->extensions->getExtensions();
$extensionsEnabled = json_decode($this->settings->get('extensions_enabled', '{}'), true);
$csrfToken = $request->getAttribute('session')->token();
$mysqlVersion = $document->payload['mysqlVersion'];
$phpVersion = $document->payload['phpVersion'];
$flarumVersion = Application::VERSION;
$document->content = $this->view->make(
'flarum.admin::frontend.content.admin',
compact('extensions', 'extensionsEnabled', 'csrfToken', 'flarumVersion', 'phpVersion', 'mysqlVersion')
);
return $document;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Admin\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Extension\Command\ToggleExtension;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
class UpdateExtensionController implements RequestHandlerInterface
{
/**
* @var UrlGenerator
*/
protected $url;
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(UrlGenerator $url, Dispatcher $bus)
{
$this->url = $url;
$this->bus = $bus;
}
/**
* {@inheritdoc}
*/
public function handle(Request $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$enabled = (bool) (int) Arr::get($request->getParsedBody(), 'enabled');
$name = Arr::get($request->getQueryParams(), 'name');
$this->bus->dispatch(
new ToggleExtension($actor, $name, $enabled)
);
return new RedirectResponse($this->url->to('admin')->base());
}
}

View File

@@ -0,0 +1,25 @@
<?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\Admin\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface as Middleware;
use Psr\Http\Server\RequestHandlerInterface as Handler;
class DisableBrowserCache implements Middleware
{
public function process(Request $request, Handler $handler): Response
{
$response = $handler->handle($request);
return $response->withHeader('Cache-Control', 'max-age=0, no-store');
}
}

View File

@@ -7,6 +7,8 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Admin\Content\Index;
use Flarum\Admin\Controller\UpdateExtensionController;
use Flarum\Http\RouteCollection;
use Flarum\Http\RouteHandlerFactory;
@@ -14,6 +16,12 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$map->get(
'/',
'index',
$route->toAdmin()
$route->toAdmin(Index::class)
);
$map->post(
'/extensions/{name}',
'extensions.update',
$route->toController(UpdateExtensionController::class)
);
};

View File

@@ -132,6 +132,7 @@ class Client
if ($this->parent) {
$request = $request
->withAttribute('ipAddress', $this->parent->getAttribute('ipAddress'))
->withAttribute('session', $this->parent->getAttribute('session'));
$request = RequestUtil::withActor($request, RequestUtil::getActor($this->parent));
}

View File

@@ -148,13 +148,9 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
abstract protected function createElement($data, SerializerInterface $serializer);
/**
* Eager loads the required relationships.
*
* @param Collection $models
* @param array $relations
* @return void
* Returns the relations to load added by extenders.
*/
protected function loadRelations(Collection $models, array $relations): void
protected function getRelationsToLoad(): array
{
$addedRelations = [];
@@ -164,6 +160,20 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
}
}
return $addedRelations;
}
/**
* Eager loads the required relationships.
*
* @param Collection $models
* @param array $relations
* @return void
*/
protected function loadRelations(Collection $models, array $relations): void
{
$addedRelations = $this->getRelationsToLoad();
if (! empty($addedRelations)) {
usort($addedRelations, function ($a, $b) {
return substr_count($a, '.') - substr_count($b, '.');
@@ -226,7 +236,7 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
*/
protected function extractOffset(ServerRequestInterface $request)
{
return $this->buildParameters($request)->getOffset($this->extractLimit($request)) ?: 0;
return (int) $this->buildParameters($request)->getOffset($this->extractLimit($request)) ?: 0;
}
/**
@@ -235,7 +245,7 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
*/
protected function extractLimit(ServerRequestInterface $request)
{
return $this->buildParameters($request)->getLimit($this->maxLimit) ?: $this->limit;
return (int) $this->buildParameters($request)->getLimit($this->maxLimit) ?: $this->limit;
}
/**

View File

@@ -10,8 +10,10 @@
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\Group;
use Flarum\Group\Filter\GroupFilterer;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
@@ -22,6 +24,38 @@ class ListGroupsController extends AbstractListController
*/
public $serializer = GroupSerializer::class;
/**
* {@inheritdoc}
*/
public $sortFields = ['nameSingular', 'namePlural', 'isHidden'];
/**
* {@inheritdoc}
*
* @var int
*/
public $limit = -1;
/**
* @var GroupFilterer
*/
protected $filterer;
/**
* @var UrlGenerator
*/
protected $url;
/**
* @param GroupFilterer $filterer
* @param UrlGenerator $url
*/
public function __construct(GroupFilterer $filterer, UrlGenerator $url)
{
$this->filterer = $filterer;
$this->url = $url;
}
/**
* {@inheritdoc}
*/
@@ -29,10 +63,25 @@ class ListGroupsController extends AbstractListController
{
$actor = RequestUtil::getActor($request);
$results = Group::whereVisibleTo($actor)->get();
$filters = $this->extractFilter($request);
$sort = $this->extractSort($request);
$sortIsDefault = $this->sortIsDefault($request);
$this->loadRelations($results, []);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
return $results;
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault);
$queryResults = $this->filterer->filter($criteria, $limit, $offset);
$document->addPaginationLinks(
$this->url->to('api')->route('groups.index'),
$request->getQueryParams(),
$offset,
$limit,
$queryResults->areMoreResults() ? null : 0
);
return $queryResults->getResults();
}
}

View File

@@ -187,12 +187,21 @@ class ShowDiscussionController extends AbstractShowController
$query->orderBy('created_at')->skip($offset)->take($limit)->with($include);
$posts = $query->get()->all();
$posts = $query->get();
foreach ($posts as $post) {
$post->discussion = $discussion;
}
return $posts;
$this->loadRelations($posts, $include);
return $posts->all();
}
protected function getRelationsToLoad(): array
{
$addedRelations = parent::getRelationsToLoad();
return $this->getPostRelationships($addedRelations);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\GroupRepository;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowGroupController extends AbstractShowController
{
/**
* @var GroupRepository
*/
protected $groups;
/**
* {@inheritdoc}
*/
public $serializer = GroupSerializer::class;
/**
* @param \Flarum\Group\GroupRepository $groups
*/
public function __construct(GroupRepository $groups)
{
$this->groups = $groups;
}
/**
* {@inheritdoc}
*/
protected function data(ServerRequestInterface $request, Document $document)
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$group = $this->groups->findOrFail($id, $actor);
return $group;
}
}

View File

@@ -9,7 +9,8 @@
namespace Flarum\Api\Controller;
use Flarum\Extension\ExtensionManager;
use Flarum\Bus\Dispatcher;
use Flarum\Extension\Command\ToggleExtension;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -20,16 +21,13 @@ use Psr\Http\Server\RequestHandlerInterface;
class UpdateExtensionController implements RequestHandlerInterface
{
/**
* @var ExtensionManager
* @var Dispatcher
*/
protected $extensions;
protected $bus;
/**
* @param ExtensionManager $extensions
*/
public function __construct(ExtensionManager $extensions)
public function __construct(Dispatcher $bus)
{
$this->extensions = $extensions;
$this->bus = $bus;
}
/**
@@ -37,16 +35,13 @@ class UpdateExtensionController implements RequestHandlerInterface
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
RequestUtil::getActor($request)->assertAdmin();
$enabled = Arr::get($request->getParsedBody(), 'enabled');
$actor = RequestUtil::getActor($request);
$enabled = (bool) (int) Arr::get($request->getParsedBody(), 'enabled');
$name = Arr::get($request->getQueryParams(), 'name');
if ($enabled === true) {
$this->extensions->enable($name);
} elseif ($enabled === false) {
$this->extensions->disable($name);
}
$this->bus->dispatch(
new ToggleExtension($actor, $name, $enabled)
);
return new EmptyResponse;
}

View File

@@ -9,9 +9,13 @@
namespace Flarum\Api\Controller;
use Flarum\Foundation\ValidationException;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Factory;
use Intervention\Image\Image;
use Intervention\Image\ImageManager;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class UploadFaviconController extends UploadImageController
{
@@ -19,6 +23,22 @@ class UploadFaviconController extends UploadImageController
protected $filenamePrefix = 'favicon';
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param SettingsRepositoryInterface $settings
* @param Factory $filesystemFactory
*/
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory, TranslatorInterface $translator)
{
parent::__construct($settings, $filesystemFactory);
$this->translator = $translator;
}
/**
* {@inheritdoc}
*/
@@ -27,18 +47,24 @@ class UploadFaviconController extends UploadImageController
$this->fileExtension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
if ($this->fileExtension === 'ico') {
$encodedImage = $file->getStream();
} else {
$manager = new ImageManager();
$encodedImage = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})->encode('png');
$this->fileExtension = 'png';
// @todo remove in 2.0
throw new ValidationException([
'message' => strtr($this->translator->trans('validation.mimes'), [
':attribute' => 'favicon',
':values' => 'jpeg,png,gif,webp',
])
]);
}
$manager = new ImageManager();
$encodedImage = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})->encode('png');
$this->fileExtension = 'png';
return $encodedImage;
}
}

View File

@@ -9,12 +9,30 @@
namespace Flarum\Api\Serializer;
use Exception;
use Flarum\Foundation\ErrorHandling\LogReporter;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use InvalidArgumentException;
use Symfony\Contracts\Translation\TranslatorInterface;
class BasicPostSerializer extends AbstractSerializer
{
/**
* @var LogReporter
*/
protected $log;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(LogReporter $log, TranslatorInterface $translator)
{
$this->log = $log;
$this->translator = $translator;
}
/**
* {@inheritdoc}
*/
@@ -41,7 +59,14 @@ class BasicPostSerializer extends AbstractSerializer
];
if ($post instanceof CommentPost) {
$attributes['contentHtml'] = $post->formatContent($this->request);
try {
$attributes['contentHtml'] = $post->formatContent($this->request);
$attributes['renderFailed'] = false;
} catch (Exception $e) {
$attributes['contentHtml'] = $this->translator->trans('core.lib.error.render_failed_message');
$this->log->report($e);
$attributes['renderFailed'] = true;
}
} else {
$attributes['content'] = $post->content;
}

View File

@@ -224,6 +224,13 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$route->toController(Controller\CreateGroupController::class)
);
// Show a single group
$map->get(
'/groups/{id}',
'groups.show',
$route->toController(Controller\ShowGroupController::class)
);
// Edit a group
$map->patch(
'/groups/{id}',

View File

@@ -16,6 +16,7 @@ use Flarum\Foundation\ContainerUtil;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Frontend\Assets;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Flarum\Frontend\Document;
use Flarum\Frontend\Frontend as ActualFrontend;
use Flarum\Frontend\RecompileFrontendAssets;
use Flarum\Http\RouteCollection;
@@ -33,6 +34,7 @@ class Frontend implements ExtenderInterface
private $routes = [];
private $removedRoutes = [];
private $content = [];
private $preloadArrs = [];
/**
* @param string $frontend: The name of the frontend.
@@ -124,11 +126,45 @@ class Frontend implements ExtenderInterface
return $this;
}
/**
* Adds multiple asset preloads.
*
* The parameter should be an array of preload arrays, or a callable that returns this.
*
* A preload array must contain keys that pertain to the `<link rel="preload">` tag.
*
* For example, the following will add preload tags for a script and font file:
* ```
* $frontend->preloads([
* [
* 'href' => '/assets/my-script.js',
* 'as' => 'script',
* ],
* [
* 'href' => '/assets/fonts/my-font.woff2',
* 'as' => 'font',
* 'type' => 'font/woff2',
* 'crossorigin' => ''
* ]
* ]);
* ```
*
* @param callable|array $preloads
* @return self
*/
public function preloads($preloads): self
{
$this->preloadArrs[] = $preloads;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$this->registerAssets($container, $this->getModuleName($extension));
$this->registerRoutes($container);
$this->registerContent($container);
$this->registerPreloads($container);
}
private function registerAssets(Container $container, string $moduleName): void
@@ -153,9 +189,9 @@ class Frontend implements ExtenderInterface
}
if ($this->css) {
$assets->css(function (SourceCollector $sources) {
$assets->css(function (SourceCollector $sources) use ($moduleName) {
foreach ($this->css as $path) {
$sources->addFile($path);
$sources->addFile($path, $moduleName);
}
});
}
@@ -236,6 +272,25 @@ class Frontend implements ExtenderInterface
);
}
private function registerPreloads(Container $container): void
{
if (empty($this->preloadArrs)) {
return;
}
$container->resolving(
"flarum.frontend.$this->frontend",
function (ActualFrontend $frontend, Container $container) {
$frontend->content(function (Document $document) use ($container) {
foreach ($this->preloadArrs as $preloadArr) {
$preloads = is_callable($preloadArr) ? ContainerUtil::wrapCallback($preloadArr, $container)($document) : $preloadArr;
$document->preloads = array_merge($document->preloads, $preloads);
}
});
}
);
}
private function getModuleName(?Extension $extension): string
{
return $extension ? $extension->getId() : 'site-custom';

View File

@@ -21,7 +21,7 @@ class ServiceProvider implements ExtenderInterface
*
* Service providers are an advanced feature and might give access to Flarum internals that do not come with backward compatibility.
* Please read our documentation about service providers for recommendations.
* @see https://docs.flarum.org/extend/service-provider.html
* @see https://docs.flarum.org/extend/service-provider/
*
* @param string $serviceProviderClass The ::class attribute of the service provider class.
* @return self

69
src/Extend/Theme.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\Frontend\Assets;
use Illuminate\Contracts\Container\Container;
class Theme implements ExtenderInterface
{
private $lessImportOverrides = [];
private $fileSourceOverrides = [];
/**
* This can be used to override LESS files that are imported within the code.
* For example, core's `forum.less` file imports a `forum/DiscussionListItem.less` file.
* The contents of this file can be overriden with this method.
*
* @param string $file : Relative path of the file to override, for example: `forum/Hero.less`
* @param string $newFilePath : Absolute path of the new file.
* @param string|null $extensionId : If overriding an extension file, specify its ID, for example: `flarum-tags`.
* @return self
*/
public function overrideLessImport(string $file, string $newFilePath, string $extensionId = null): self
{
$this->lessImportOverrides[] = compact('file', 'newFilePath', 'extensionId');
return $this;
}
/**
* This method allows overriding LESS file sources.
* For example `forum.less`, `admin.less`, `mixins.less` and `variables.less` are file sources,
* and can therefore be overriden using this method.
*
* @param string $file : Name of the file to override, for example: `admin.less`
* @param string $newFilePath : Absolute path of the new file.
* @param string|null $extensionId : If overriding an extension file, specify its ID, for example: `flarum-tags`.
* @return self
*/
public function overrideFileSource(string $file, string $newFilePath, string $extensionId = null): self
{
$this->fileSourceOverrides[] = compact('file', 'newFilePath', 'extensionId');
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.assets.factory', function (callable $factory) {
return function (...$args) use ($factory) {
/** @var Assets $assets */
$assets = $factory(...$args);
$assets->addLessImportOverrides($this->lessImportOverrides);
$assets->addFileSourceOverrides($this->fileSourceOverrides);
return $assets;
};
});
}
}

View File

@@ -0,0 +1,37 @@
<?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\Extension\Command;
use Flarum\User\User;
class ToggleExtension
{
/**
* @var User
*/
public $actor;
/**
* @var string
*/
public $name;
/**
* @var bool
*/
public $enabled;
public function __construct(User $actor, string $name, bool $enabled)
{
$this->actor = $actor;
$this->name = $name;
$this->enabled = $enabled;
}
}

View File

@@ -0,0 +1,41 @@
<?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\Extension\Command;
use Flarum\Extension\ExtensionManager;
class ToggleExtensionHandler
{
/**
* @var ExtensionManager
*/
protected $extensions;
public function __construct(ExtensionManager $extensions)
{
$this->extensions = $extensions;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws \Flarum\Extension\Exception\MissingDependenciesException
* @throws \Flarum\Extension\Exception\DependentExtensionsException
*/
public function handle(ToggleExtension $command)
{
$command->actor->assertAdmin();
if ($command->enabled) {
$this->extensions->enable($command->name);
} else {
$this->extensions->disable($command->name);
}
}
}

View File

@@ -279,6 +279,27 @@ class Extension implements Arrayable
return $icon;
}
public function getIconStyles(): string
{
$properties = $this->getIcon();
if (empty($properties)) {
return '';
}
$properties = array_filter($properties, function ($item) {
return is_string($item);
});
unset($properties['name']);
return implode(';', array_map(function (string $property, string $value) {
$property = Str::kebab($property);
return "$property: $value";
}, array_keys($properties), $properties));
}
/**
* @internal
*/

View File

@@ -421,7 +421,7 @@ class ExtensionManager
* Sort a list of extensions so that they are properly resolved in respect to order.
* Effectively just topological sorting.
*
* @param Extension[] $extensionList: an array of \Flarum\Extension\Extension objects
* @param Extension[] $extensionList
*
* @return array with 2 keys: 'valid' points to an ordered array of \Flarum\Extension\Extension
* 'missingDependencies' points to an associative array of extensions that could not be resolved due
@@ -443,6 +443,12 @@ class ExtensionManager
$pendingQueue = [];
$inDegreeCount = []; // How many extensions are dependent on a given extension?
// Sort alphabetically by ID. This guarantees that any set of extensions will always be sorted the same way.
// This makes boot order deterministic, and independent of enabled order.
$extensionList = Arr::sort($extensionList, function ($ext) {
return $ext->getId();
});
foreach ($extensionList as $extension) {
$extensionIdMapping[$extension->getId()] = $extension;
}

View File

@@ -13,6 +13,8 @@ use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Query as DiscussionQuery;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ContainerUtil;
use Flarum\Group\Filter as GroupFilter;
use Flarum\Group\Filter\GroupFilterer;
use Flarum\Post\Filter as PostFilter;
use Flarum\Post\Filter\PostFilterer;
use Flarum\User\Filter\UserFilterer;
@@ -41,6 +43,9 @@ class FilterServiceProvider extends AbstractServiceProvider
UserQuery\EmailFilterGambit::class,
UserQuery\GroupFilterGambit::class,
],
GroupFilterer::class => [
GroupFilter\HiddenFilter::class,
],
PostFilterer::class => [
PostFilter\AuthorFilter::class,
PostFilter\DiscussionFilter::class,

View File

@@ -68,12 +68,13 @@ class Index
$sort = Arr::pull($queryParams, 'sort');
$q = Arr::pull($queryParams, 'q');
$page = max(1, intval(Arr::pull($queryParams, 'page')));
$filters = Arr::pull($queryParams, 'filter', []);
$sortMap = $this->getSortMap();
$params = [
'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : '',
'filter' => [],
'filter' => $filters,
'page' => ['offset' => ($page - 1) * 20, 'limit' => 20]
];

View File

@@ -21,7 +21,7 @@ class Application
*
* @var string
*/
const VERSION = '1.0.5-dev';
const VERSION = '1.2.0-dev';
/**
* The IoC container for the Flarum application.

View File

@@ -52,6 +52,16 @@ class Assets
*/
protected $lessImportDirs;
/**
* @var array
*/
protected $lessImportOverrides = [];
/**
* @var array
*/
protected $fileSourceOverrides = [];
public function __construct(string $name, Filesystem $assetsDir, string $cacheDir = null, array $lessImportDirs = null)
{
$this->name = $name;
@@ -155,6 +165,14 @@ class Assets
$compiler->setImportDirs($this->lessImportDirs);
}
if ($this->lessImportOverrides) {
$compiler->setLessImportOverrides($this->lessImportOverrides);
}
if ($this->fileSourceOverrides) {
$compiler->setFileSourceOverrides($this->fileSourceOverrides);
}
return $compiler;
}
@@ -197,4 +215,14 @@ class Assets
{
$this->lessImportDirs = $lessImportDirs;
}
public function addLessImportOverrides(array $lessImportOverrides)
{
$this->lessImportOverrides = array_merge($this->lessImportOverrides, $lessImportOverrides);
}
public function addFileSourceOverrides(array $fileSourceOverrides)
{
$this->fileSourceOverrides = array_merge($this->fileSourceOverrides, $fileSourceOverrides);
}
}

View File

@@ -10,6 +10,8 @@
namespace Flarum\Frontend\Compiler;
use Flarum\Frontend\Compiler\Source\FileSource;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Less_Parser;
/**
@@ -27,6 +29,16 @@ class LessCompiler extends RevisionCompiler
*/
protected $importDirs = [];
/**
* @var Collection
*/
protected $lessImportOverrides;
/**
* @var Collection
*/
protected $fileSourceOverrides;
public function getCacheDir(): string
{
return $this->cacheDir;
@@ -47,6 +59,16 @@ class LessCompiler extends RevisionCompiler
$this->importDirs = $importDirs;
}
public function setLessImportOverrides(array $lessImportOverrides)
{
$this->lessImportOverrides = new Collection($lessImportOverrides);
}
public function setFileSourceOverrides(array $fileSourceOverrides)
{
$this->fileSourceOverrides = new Collection($fileSourceOverrides);
}
/**
* @throws \Less_Exception_Parser
*/
@@ -61,9 +83,14 @@ class LessCompiler extends RevisionCompiler
$parser = new Less_Parser([
'compress' => true,
'cache_dir' => $this->cacheDir,
'import_dirs' => $this->importDirs
'import_dirs' => $this->importDirs,
'import_callback' => $this->lessImportOverrides ? $this->overrideImports($sources) : null,
]);
if ($this->fileSourceOverrides) {
$sources = $this->overrideSources($sources);
}
foreach ($sources as $source) {
if ($source instanceof FileSource) {
$parser->parseFile($source->getPath());
@@ -75,6 +102,54 @@ class LessCompiler extends RevisionCompiler
return $parser->getCss();
}
protected function overrideSources(array $sources): array
{
foreach ($sources as $source) {
if ($source instanceof FileSource) {
$basename = basename($source->getPath());
$override = $this->fileSourceOverrides
->where('file', $basename)
->firstWhere('extensionId', $source->getExtensionId());
if ($override) {
$source->setPath($override['newFilePath']);
}
}
}
return $sources;
}
protected function overrideImports(array $sources): callable
{
$baseSources = (new Collection($sources))->filter(function ($source) {
return $source instanceof Source\FileSource;
})->map(function (FileSource $source) {
$path = realpath($source->getPath());
$path = Str::beforeLast($path, '/less/');
return [
'path' => $path,
'extensionId' => $source->getExtensionId(),
];
})->unique('path');
return function ($evald) use ($baseSources): ?array {
$relativeImportPath = Str::of($evald->PathAndUri()[0])->split('/\/less\//');
$extensionId = $baseSources->where('path', $relativeImportPath->first())->pluck('extensionId')->first();
$overrideImport = $this->lessImportOverrides
->where('file', $relativeImportPath->last())
->firstWhere('extensionId', $extensionId);
if (! $overrideImport) {
return null;
}
return [$overrideImport['newFilePath'], $evald->PathAndUri()[1]];
};
}
protected function getCacheDifferentiator(): ?array
{
return [

View File

@@ -21,16 +21,22 @@ class FileSource implements SourceInterface
*/
protected $path;
/**
* @var string
*/
protected $extensionId;
/**
* @param string $path
*/
public function __construct(string $path)
public function __construct(string $path, ?string $extensionId = null)
{
if (! file_exists($path)) {
throw new InvalidArgumentException("File not found at path: $path");
}
$this->path = $path;
$this->extensionId = $extensionId;
}
/**
@@ -56,4 +62,14 @@ class FileSource implements SourceInterface
{
return $this->path;
}
public function setPath(string $path): void
{
$this->path = $path;
}
public function getExtensionId(): ?string
{
return $this->extensionId;
}
}

View File

@@ -23,9 +23,9 @@ class SourceCollector
* @param string $file
* @return $this
*/
public function addFile(string $file)
public function addFile(string $file, string $extensionId = null)
{
$this->sources[] = new FileSource($file);
$this->sources[] = new FileSource($file, $extensionId);
return $this;
}

View File

@@ -122,6 +122,28 @@ class Document implements Renderable
*/
public $css = [];
/**
* An array of preloaded assets.
*
* Each array item should be an array containing keys that pertain to the
* `<link rel="preload">` tag.
*
* For example, the following will add a preload tag for a FontAwesome font file:
* ```
* $this->preloads[] = [
* 'href' => '/assets/fonts/fa-solid-900.woff2',
* 'as' => 'font',
* 'type' => 'font/woff2',
* 'crossorigin' => ''
* ];
* ```
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload
*
* @var array
*/
public $preloads = [];
/**
* @var Factory
*/
@@ -203,6 +225,19 @@ class Document implements Renderable
return $this->view->make($this->contentView)->with('content', $this->content);
}
protected function makePreloads(): array
{
return array_map(function ($preload) {
$attributes = '';
foreach ($preload as $key => $value) {
$attributes .= " $key=\"".e($value).'"';
}
return "<link rel=\"preload\"$attributes>";
}, $this->preloads);
}
/**
* @return string
*/
@@ -216,6 +251,8 @@ class Document implements Renderable
$head[] = '<link rel="canonical" href="'.e($this->canonicalUrl).'">';
}
$head = array_merge($head, $this->makePreloads());
$head = array_merge($head, array_map(function ($content, $name) {
return '<meta name="'.e($name).'" content="'.e($content).'">';
}, $this->meta, array_keys($this->meta)));

View File

@@ -54,9 +54,58 @@ class FrontendServiceProvider extends AbstractServiceProvider
$frontend->content($container->make(Content\CorePayload::class));
$frontend->content($container->make(Content\Meta::class));
$frontend->content(function (Document $document) use ($container) {
$default_preloads = $container->make('flarum.frontend.default_preloads');
// Add preloads for base CSS and JS assets. Extensions should add their own via the extender.
$js_preloads = [];
$css_preloads = [];
foreach ($document->css as $url) {
$css_preloads[] = [
'href' => $url,
'as' => 'style'
];
}
foreach ($document->js as $url) {
$css_preloads[] = [
'href' => $url,
'as' => 'script'
];
}
$document->preloads = array_merge(
$css_preloads,
$js_preloads,
$default_preloads,
$document->preloads,
);
});
return $frontend;
};
});
$this->container->singleton(
'flarum.frontend.default_preloads',
function (Container $container) {
$filesystem = $container->make('filesystem')->disk('flarum-assets');
return [
[
'href' => $filesystem->url('fonts/fa-solid-900.woff2'),
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => ''
], [
'href' => $filesystem->url('fonts/fa-regular-400.woff2'),
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => ''
]
];
}
);
}
/**

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Group\Filter;
use Flarum\Filter\AbstractFilterer;
use Flarum\Group\GroupRepository;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class GroupFilterer extends AbstractFilterer
{
/**
* @var GroupRepository
*/
protected $groups;
/**
* @param GroupRepository $groups
* @param array $filters
* @param array $filterMutators
*/
public function __construct(GroupRepository $groups, array $filters, array $filterMutators)
{
parent::__construct($filters, $filterMutators);
$this->groups = $groups;
}
protected function getQuery(User $actor): Builder
{
return $this->groups->query()->whereVisibleTo($actor);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
class HiddenFilter implements FilterInterface
{
public function getFilterKey(): string
{
return 'hidden';
}
public function filter(FilterState $filterState, string $filterValue, bool $negate)
{
$filterState->getQuery()->where('is_hidden', $negate ? '!=' : '=', $filterValue);
}
}

View File

@@ -21,7 +21,7 @@ class GroupRepository
*/
public function query()
{
return User::query();
return Group::query();
}
/**

View File

@@ -47,6 +47,8 @@ class AuthenticateWithHeader implements Middleware
}
if (isset($actor)) {
$actor->updateLastSeen()->save();
$request = RequestUtil::withActor($request, $actor);
$request = $request->withAttribute('bypassCsrfToken', true);
$request = $request->withoutAttribute('session');

View File

@@ -121,11 +121,17 @@ class RouteCollection
return $this->dataGenerator->getData();
}
protected function fixPathPart(&$part, $key, array $parameters)
protected function fixPathPart($part, array $parameters, string $routeName)
{
if (is_array($part) && array_key_exists($part[0], $parameters)) {
$part = $parameters[$part[0]];
if (! is_array($part)) {
return $part;
}
if (! array_key_exists($part[0], $parameters)) {
throw new \InvalidArgumentException("Could not generate URL for route '$routeName': no value provided for required part '$part[0]'.");
}
return $parameters[$part[0]];
}
public function getPath($name, array $parameters = [])
@@ -151,9 +157,11 @@ class RouteCollection
}
}
array_walk($matchingParts, [$this, 'fixPathPart'], $parameters);
$fixedParts = array_map(function ($part) use ($parameters, $name) {
return $this->fixPathPart($part, $parameters, $name);
}, $matchingParts);
return '/'.ltrim(implode('', $matchingParts), '/');
return '/'.ltrim(implode('', $fixedParts), '/');
}
throw new \RuntimeException("Route $name not found");

View File

@@ -87,8 +87,8 @@ class DatabaseConfig implements Arrayable
}
if (! empty($this->prefix)) {
if (! preg_match('/^[\pL\pM\pN_-]+$/u', $this->prefix)) {
throw new ValidationFailed('The prefix may only contain characters, dashes and underscores.');
if (! preg_match('/^[\pL\pM\pN_]+$/u', $this->prefix)) {
throw new ValidationFailed('The prefix may only contain characters and underscores.');
}
if (strlen($this->prefix) > 10) {

View File

@@ -53,7 +53,7 @@ class WritablePaths implements PrerequisiteInterface
})->map(function ($path, $index) {
return [
'message' => 'The '.$this->getAbsolutePath($path).' directory is not writable.',
'detail' => 'Please make sure your web server/PHP user has write access to this directory'.(in_array($index, $this->wildcards) ? ' and its contents' : '').'. Read the <a href="https://docs.flarum.org/install.html#folder-ownership">installation documentation</a> for a detailed explanation and steps to resolve this error.'
'detail' => 'Please make sure your web server/PHP user has write access to this directory'.(in_array($index, $this->wildcards) ? ' and its contents' : '').'. Read the <a href="https://docs.flarum.org/install/#folder-ownership">installation documentation</a> for a detailed explanation and steps to resolve this error.'
];
});
}

View File

@@ -16,6 +16,11 @@ use Symfony\Component\Mime\MimeTypes;
class AvatarValidator extends AbstractValidator
{
/**
* @var \Illuminate\Validation\Validator
*/
protected $laravelValidator;
/**
* Throw an exception if a model is not valid.
*
@@ -23,6 +28,8 @@ class AvatarValidator extends AbstractValidator
*/
public function assertValid(array $attributes)
{
$this->laravelValidator = $this->makeValidator($attributes);
$this->assertFileRequired($attributes['avatar']);
$this->assertFileMimes($attributes['avatar']);
$this->assertFileSize($attributes['avatar']);
@@ -30,8 +37,18 @@ class AvatarValidator extends AbstractValidator
protected function assertFileRequired(UploadedFileInterface $file)
{
if ($file->getError() !== UPLOAD_ERR_OK) {
$this->raise('required');
$error = $file->getError();
if ($error !== UPLOAD_ERR_OK) {
if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
$this->raise('file_too_large');
}
if ($error === UPLOAD_ERR_NO_FILE) {
$this->raise('required');
}
$this->raise('file_upload_failed');
}
}
@@ -59,15 +76,21 @@ class AvatarValidator extends AbstractValidator
$maxSize = $this->getMaxSize();
if ($file->getSize() / 1024 > $maxSize) {
$this->raise('max.file', [':max' => $maxSize]);
$this->raise('max.file', [':max' => $maxSize], 'max');
}
}
protected function raise($error, array $parameters = [])
protected function raise($error, array $parameters = [], $rule = null)
{
$message = $this->translator->trans(
"validation.$error",
$parameters + [':attribute' => 'avatar']
// When we switched to intl ICU message format, the translation parameters
// have become required to be in the format `{param}`.
// Therefore we cannot use the translator to replace the string params.
// We use the laravel validator to make the replacements instead.
$message = $this->laravelValidator->makeReplacements(
$this->translator->trans("validation.$error"),
'avatar',
$rule ?? $error,
array_values($parameters)
);
throw new ValidationException(['avatar' => $message]);

3
tests/fixtures/less/Imported.less vendored Normal file
View File

@@ -0,0 +1,3 @@
.Imported {
// ...
}

1
tests/fixtures/less/dummy.less vendored Normal file
View File

@@ -0,0 +1 @@
.dummy_test_case{color:red}

5
tests/fixtures/less/forum.less vendored Normal file
View File

@@ -0,0 +1,5 @@
@import 'Imported';
.dummy {
color: yellow;
}

View File

@@ -0,0 +1,3 @@
body {
color: orange;
}

View File

@@ -65,6 +65,148 @@ class ListTest extends TestCase
$this->assertEquals(['1', '2', '3', '4', '10'], Arr::pluck($data['data'], 'id'));
}
/**
* @test
*/
public function filters_only_public_groups_for_admin()
{
$response = $this->send(
$this->request('GET', '/api/groups', [
'authenticatedAs' => 1,
])
->withQueryParams([
'filter' => ['hidden' => 0],
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// The four default groups created by the installer without our hidden group
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));
}
/**
* @test
*/
public function filters_only_hidden_groups_for_admin()
{
$response = $this->send(
$this->request('GET', '/api/groups', [
'authenticatedAs' => 1,
])
->withQueryParams([
'filter' => ['hidden' => 1],
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Only our hidden group
$this->assertEquals(['10'], Arr::pluck($data['data'], 'id'));
}
/**
* @test
*/
public function filters_only_public_groups_for_guest()
{
$response = $this->send(
$this->request('GET', '/api/groups')
->withQueryParams([
'filter' => ['hidden' => 0],
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// The four default groups created by the installer without our hidden group
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));
}
/**
* @test
*/
public function hides_hidden_groups_when_filtering_for_guest()
{
$response = $this->send(
$this->request('GET', '/api/groups')
->withQueryParams([
'filter' => ['hidden' => 1],
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// When guest attempts to filter for hidden groups, system should
// still apply scoping and exclude those groups from results
$this->assertEquals([], Arr::pluck($data['data'], 'id'));
}
/**
* @test
*/
public function paginates_groups_without_filter()
{
$response = $this->send(
$this->request('GET', '/api/groups')
->withQueryParams([
'page' => ['limit' => '2', 'offset' => '2'],
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Show second page of groups
$this->assertEquals(['3', '4'], Arr::pluck($data['data'], 'id'));
}
/**
* @test
*/
public function paginates_groups_with_filter()
{
$response = $this->send(
$this->request('GET', '/api/groups')
->withQueryParams([
'filter' => ['hidden' => 1],
'page' => ['limit' => '1', 'offset' => '1'],
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Show second page of groups. Because there is only one hidden group,
// second page should be empty.
$this->assertEmpty($data['data']);
}
/**
* @test
*/
public function sorts_groups_by_name()
{
$response = $this->send(
$this->request('GET', '/api/groups', [
'authenticatedAs' => 1,
])
->withQueryParams([
'sort' => 'nameSingular',
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Ascending alphabetical order is: Admin - Guest - Hidden - Member - Mod
$this->assertEquals(['1', '2', '10', '3', '4'], Arr::pluck($data['data'], 'id'));
}
protected function hiddenGroup(): array
{
return [

View File

@@ -0,0 +1,126 @@
<?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\groups;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Illuminate\Support\Arr;
class ShowTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->prepareDatabase([
'groups' => [
$this->hiddenGroup(),
],
]);
}
/**
* @test
*/
public function shows_public_group_for_guest()
{
$response = $this->send(
$this->request('GET', '/api/groups/1')
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Default group created by the installer should be returned
$this->assertEquals('1', Arr::get($data, 'data.id'));
}
/**
* @test
*/
public function shows_public_group_for_admin()
{
$response = $this->send(
$this->request('GET', '/api/groups/1', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Default group created by the installer should be returned
$this->assertEquals('1', Arr::get($data, 'data.id'));
}
/**
* @test
*/
public function hides_hidden_group_for_guest()
{
$response = $this->send(
$this->request('GET', '/api/groups/10')
);
// Hidden group should not be returned for guest
$this->assertEquals(404, $response->getStatusCode());
}
/**
* @test
*/
public function shows_hidden_group_for_admin()
{
$response = $this->send(
$this->request('GET', '/api/groups/10', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
// Hidden group should be returned for admin
$this->assertEquals('10', Arr::get($data, 'data.id'));
}
/**
* @test
*/
public function rejects_request_for_non_existing_group()
{
$response = $this->send(
$this->request('GET', '/api/groups/999', [
'authenticatedAs' => 1,
])
);
// If group does not exist in database, controller
// should reject the request with 404 Not found
$this->assertEquals(404, $response->getStatusCode());
}
protected function hiddenGroup(): array
{
return [
'id' => 10,
'name_singular' => 'Hidden',
'name_plural' => 'Ninjas',
'color' => null,
'icon' => 'fas fa-wrench',
'is_hidden' => 1
];
}
}

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\api\posts;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class ShowTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->prepareDatabase([
'discussions' => [
['id' => 1, 'title' => 'Discussion with post', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1, 'is_private' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>valid</p></t>'],
['id' => 2, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<tMALFORMED'],
],
'users' => [
$this->normalUser(),
]
]);
}
/**
* @test
*/
public function properly_formatted_post_rendered_correctly()
{
$response = $this->send(
$this->request('GET', '/api/posts/1', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = (string) $response->getBody();
$this->assertJson($body);
$data = json_decode($body, true);
$this->assertEquals($data['data']['attributes']['contentHtml'], '<p>valid</p>');
}
/**
* @test
*/
public function malformed_post_caught_by_renderer()
{
$response = $this->send(
$this->request('GET', '/api/posts/2', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = (string) $response->getBody();
$this->assertJson($body);
$data = json_decode($body, true);
$this->assertEquals("Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.", $data['data']['attributes']['contentHtml']);
}
}

View File

@@ -0,0 +1,93 @@
<?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\Testing\integration\TestCase;
class FrontendPreloadTest extends TestCase
{
private $customPreloadUrls = ['/my-preload', '/my-preload2'];
/**
* @test
*/
public function default_preloads_are_present()
{
$response = $this->send(
$this->request('GET', '/')
);
$filesystem = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets');
$urls = [
$filesystem->url('fonts/fa-solid-900.woff2'),
$filesystem->url('fonts/fa-regular-400.woff2'),
];
$body = $response->getBody()->getContents();
foreach ($urls as $url) {
$this->assertStringContainsString("<link rel=\"preload\" href=\"$url\" as=\"font\" type=\"font/woff2\" crossorigin=\"\">", $body);
}
}
/**
* @test
*/
public function preloads_can_be_added()
{
$urls = $this->customPreloadUrls;
$this->extend(
(new Extend\Frontend('forum'))
->preloads(
array_map(function ($url) {
return ['href' => $url];
}, $urls)
)
);
$response = $this->send(
$this->request('GET', '/')
);
$body = $response->getBody()->getContents();
foreach ($urls as $url) {
$this->assertStringContainsString("<link rel=\"preload\" href=\"$url\">", $body);
}
}
/**
* @test
*/
public function preloads_can_be_added_via_callable()
{
$urls = $this->customPreloadUrls;
$this->extend(
(new Extend\Frontend('forum'))
->preloads(function () use ($urls) {
return array_map(function ($url) {
return ['href' => $url];
}, $urls);
})
);
$response = $this->send(
$this->request('GET', '/')
);
$body = $response->getBody()->getContents();
foreach ($urls as $url) {
$this->assertStringContainsString("<link rel=\"preload\" href=\"$url\">", $body);
}
}
}

View File

@@ -0,0 +1,107 @@
<?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\Testing\integration\TestCase;
class ThemeTest extends TestCase
{
/**
* @test
*/
public function theme_extender_override_import_doesnt_work_by_default()
{
$response = $this->send($this->request('GET', '/'));
$this->assertEquals(200, $response->getStatusCode());
$cssFilePath = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->path('forum.css');
$this->assertStringNotContainsString('.dummy_test_case{color:red}', file_get_contents($cssFilePath));
}
/**
* @test
*/
public function theme_extender_override_import_works()
{
$this->extend(
(new Extend\Theme)
->overrideLessImport('forum/Hero.less', __DIR__.'/../../fixtures/less/dummy.less')
);
$response = $this->send($this->request('GET', '/'));
$this->assertEquals(200, $response->getStatusCode());
$cssFilePath = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->path('forum.css');
$this->assertStringContainsString('.dummy_test_case{color:red}', file_get_contents($cssFilePath));
}
/**
* @test
*/
public function theme_extender_override_import_works_with_external_sources()
{
$this->extend(
(new Extend\Frontend('forum'))
->css(__DIR__.'/../../fixtures/less/forum.less'),
(new Extend\Theme)
->overrideLessImport('Imported.less', __DIR__.'/../../fixtures/less/dummy.less', 'site-custom')
);
$response = $this->send($this->request('GET', '/'));
$this->assertEquals(200, $response->getStatusCode());
$cssFilePath = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->path('forum.css');
$contents = file_get_contents($cssFilePath);
$this->assertStringNotContainsString('.Imported', $contents);
$this->assertStringContainsString('.dummy_test_case{color:red}', $contents);
$this->assertStringContainsString('.dummy{color:yellow}', $contents);
}
/**
* @test
*/
public function theme_extender_override_file_source_works()
{
$this->extend(
(new Extend\Theme)
->overrideFileSource('forum.less', __DIR__.'/../../fixtures/less/override_filesource.less')
);
$response = $this->send($this->request('GET', '/'));
$this->assertEquals(200, $response->getStatusCode());
$cssFilePath = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->path('forum.css');
$this->assertEquals('body{color:orange}', file_get_contents($cssFilePath));
}
/**
* @test
*/
public function theme_extender_override_file_source_works_by_failing_when_necessary()
{
$this->extend(
(new Extend\Theme)
->overrideFileSource('mixins.less', __DIR__.'/../../fixtures/less/dummy.less')
);
$response = $this->send($this->request('GET', '/'));
$this->assertStringContainsString('Less_Exception_Compiler', $response->getBody()->getContents());
$this->assertEquals(500, $response->getStatusCode());
}
}

View File

@@ -44,4 +44,41 @@ class RouteCollectionTest extends TestCase
$this->assertEquals('/posts', $routeCollection->getPath('forum.posts.delete'));
}
/** @test */
public function must_provide_required_parameters()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Could not generate URL for route 'user': no value provided for required part 'user'.");
$routeCollection = (new RouteCollection)->addRoute('GET', '/user/{user}', 'user', function () {
echo 'user';
});
$routeCollection->getPath('user', []);
}
/** @test */
public function dont_need_to_provide_optional_parameters()
{
$routeCollection = (new RouteCollection)->addRoute('GET', '/user/{user}[/{test}]', 'user', function () {
echo 'user';
});
$path = $routeCollection->getPath('user', ['user' => 'SomeUser']);
$this->assertEquals('/user/SomeUser', $path);
}
/** @test */
public function can_provide_optional_parameters()
{
$routeCollection = (new RouteCollection)->addRoute('GET', '/user/{user}[/{test}]', 'user', function () {
echo 'user';
});
$path = $routeCollection->getPath('user', ['user' => 'SomeUser', 'test' => 'Flarum']);
$this->assertEquals('/user/SomeUser/Flarum', $path);
}
}

View File

@@ -28,9 +28,9 @@
<div id="admin-navigation" class="App-nav sideNav"></div>
</div>
<div id="content" class="sideNavOffset"></div>
{!! $content !!}
<div id="content" class="sideNavOffset">
{!! $content !!}
</div>
</main>
</div>

View File

@@ -0,0 +1,70 @@
@inject('url', 'Flarum\Http\UrlGenerator')
<div class="container">
<h2>{{ $translator->trans('core.views.admin.title') }}</h2>
<table class="NoJs-InfoTable Table">
<caption><h3>{{ $translator->trans('core.views.admin.info.caption') }}</h3></caption>
<tbody>
<tr>
<td>Flarum</td>
<td>{{ $flarumVersion }}</td>
</tr>
<tr>
<td>PHP</td>
<td>{{ $phpVersion }}</td>
</tr>
<tr>
<td>MySQL</td>
<td>{{ $mysqlVersion }}</td>
</tr>
</tbody>
</table>
<table class="NoJs-ExtensionsTable Table">
<caption><h3>{{ $translator->trans('core.views.admin.extensions.caption') }}</h3></caption>
<thead>
<tr>
<th></th>
<th>{{ $translator->trans('core.views.admin.extensions.name') }}</th>
<th>{{ $translator->trans('core.views.admin.extensions.package_name') }}</th>
<th>{{ $translator->trans('core.views.admin.extensions.version') }}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($extensions as $extension)
@php $isEnabled = in_array($extension->getId(), $extensionsEnabled); @endphp
<tr>
<td class="NoJs-ExtensionsTable-icon">
<div class="ExtensionIcon" style="{{ $extension->getIconStyles() }}">
<span class="icon {{ $extension->getIcon()['name'] ?? '' }}"></span>
</div>
</td>
<td class="NoJs-ExtensionsTable-title">{{ $extension->getTitle() }}</td>
<td class="NoJs-ExtensionsTable-name">{{ $extension->name }}</td>
<td class="NoJs-ExtensionsTable-version">{{ $extension->getVersion() }}</td>
<td class="NoJs-ExtensionsTable-state">
<span class="ExtensionListItem-Dot {{ $isEnabled ? 'enabled' : 'disabled' }}" aria-hidden="true"></span>
</td>
<td class="NoJs-ExtensionsTable-toggle Table-controls">
<form action="{{ $url->to('admin')->route('extensions.update', ['name' => $extension->getId()]) }}" method="POST">
<input type="hidden" name="csrfToken" value="{{ $csrfToken }}">
<input type="hidden" name="enabled" value="{{ $isEnabled ? 0 : 1 }}">
@if($isEnabled)
<button type="submit" class="Button Table-controls-item">{{ $translator->trans('core.views.admin.extensions.disable') }}</button>
@else
<button type="submit" class="Button Table-controls-item">{{ $translator->trans('core.views.admin.extensions.enable') }}</button>
@endif
</form>
</td>
</tr>
@empty
<tr><td colspan="6" class="NoJs-ExtensionsTable-empty">{{ $translator->trans('core.views.admin.extensions.empty') }}</td></tr>
@endforelse
</tbody>
</table>
</div>

View File

@@ -86,7 +86,6 @@
.form-control:focus,
.form-control.focus {
border-color: {{ $primaryColor }};
outline: none;
}
.errors {
color: #d83e3e;

View File

@@ -19,11 +19,11 @@
<input type="hidden" name="passwordToken" value="{{ $passwordToken }}">
<p class="form-group">
<input type="password" class="form-control" name="password" placeholder="{{ $translator->trans('core.views.reset_password.new_password_label') }}">
<input type="password" class="form-control" name="password" autocomplete="new-password" placeholder="{{ $translator->trans('core.views.reset_password.new_password_label') }}">
</p>
<p class="form-group">
<input type="password" class="form-control" name="password_confirmation" placeholder="{{ $translator->trans('core.views.reset_password.confirm_password_label') }}">
<input type="password" class="form-control" name="password_confirmation" autocomplete="new-password" placeholder="{{ $translator->trans('core.views.reset_password.confirm_password_label') }}">
</p>
<p class="form-group">