1
0
mirror of https://github.com/flarum/core.git synced 2025-08-16 21:34:08 +02:00

Compare commits

..

21 Commits

Author SHA1 Message Date
David Wheatley
2e57891e4d Update Button.less 2021-10-28 15:20:59 +01:00
David Wheatley
b614373a43 Apply suggestions from code review 2021-10-17 01:06:09 +01:00
David Wheatley
11a827fdb5 Remove focus removal from PR 2021-09-20 22:49:57 +01:00
David Wheatley
fb98050dea Merge branch 'master' into dw/3020-convert-link-to-button 2021-09-20 22:46:42 +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
David Wheatley
8d7ca415a5 Don't use box shadow mixin 2021-08-12 18:23:23 +02:00
David Wheatley
4eb1928b02 Replace dead links with buttons 2021-08-12 18:23:08 +02:00
55 changed files with 913 additions and 190 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.

2
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

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

@@ -107,9 +107,13 @@ export default class DiscussionListItem extends Component {
text={app.translator.trans('core.forum.discussion_list.started_text', { user, ago: humanTime(discussion.createdAt()) })}
position="right"
>
<Link className="DiscussionListItem-author" href={user ? app.route.user(user) : '#'}>
{avatar(user, { title: '' })}
</Link>
{user ? (
<Link className="DiscussionListItem-author" href={app.route.user(user)}>
{avatar(user, { title: '' })}
</Link>
) : (
<span className="DiscussionListItem-author">{avatar(user, { title: '' })}</span>
)}
</Tooltip>
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>

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

@@ -125,11 +125,17 @@ export default class LogInModal extends Modal {
footer() {
return [
<p className="LogInModal-forgotPassword">
<a onclick={this.forgotPassword.bind(this)}>{app.translator.trans('core.forum.log_in.forgot_password_link')}</a>
<Button class="Button Button--reset" onclick={this.forgotPassword.bind(this)}>
{app.translator.trans('core.forum.log_in.forgot_password_link')}
</Button>
</p>,
app.forum.attribute('allowSignUp') ? (
<p className="LogInModal-signUp">{app.translator.trans('core.forum.log_in.sign_up_text', { a: <a onclick={this.signUp.bind(this)} /> })}</p>
<p className="LogInModal-signUp">
{app.translator.trans('core.forum.log_in.sign_up_text', {
a: <Button class="Button Button--reset" onclick={this.signUp.bind(this)} />,
})}
</p>
) : (
''
),

View File

@@ -2,6 +2,7 @@ import app from '../../forum/app';
import Component from '../../common/Component';
import humanTime from '../../common/helpers/humanTime';
import fullTime from '../../common/helpers/fullTime';
import Button from '../../common/components/Button';
/**
* The `PostMeta` component displays the time of a post, and when clicked, shows
@@ -29,9 +30,9 @@ export default class PostMeta extends Component {
return (
<div className="Dropdown PostMeta">
<a className="Dropdown-toggle" onclick={selectPermalink} data-toggle="dropdown">
<Button className="Button Button--reset Dropdown-toggle" onclick={selectPermalink} data-toggle="dropdown">
{humanTime(time)}
</a>
</Button>
<div className="Dropdown-menu dropdown-menu">
<span className="PostMeta-number">{app.translator.trans('core.forum.post.number_tooltip', { number: post.number() })}</span>{' '}

View File

@@ -1,5 +1,6 @@
import app from '../../forum/app';
import Component from '../../common/Component';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import formatNumber from '../../common/utils/formatNumber';
import ScrollListener from '../../common/utils/ScrollListener';
@@ -62,9 +63,9 @@ export default class PostStreamScrubber extends Component {
<div className="Dropdown-menu dropdown-menu">
<div className="Scrubber">
<a className="Scrubber-first" onclick={this.goToFirst.bind(this)}>
<Button className="Button Button--reset Scrubber-first" onclick={this.goToFirst.bind(this)}>
{icon('fas fa-angle-double-up')} {app.translator.trans('core.forum.post_scrubber.original_post_link')}
</a>
</Button>
<div className="Scrubber-scrollbar">
<div className="Scrubber-before" />
@@ -82,9 +83,9 @@ export default class PostStreamScrubber extends Component {
</div>
</div>
<a className="Scrubber-last" onclick={this.goToLast.bind(this)}>
<Button className="Button Button--reset Scrubber-last" onclick={this.goToLast.bind(this)}>
{icon('fas fa-angle-double-down')} {app.translator.trans('core.forum.post_scrubber.now_link')}
</a>
</Button>
</div>
</div>
</div>

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

@@ -24,21 +24,21 @@ export default class SignUpModal extends Modal {
/**
* The value of the username input.
*
* @type {Function}
* @type {Stream<string>}
*/
this.username = Stream(this.attrs.username || '');
/**
* The value of the email input.
*
* @type {Function}
* @type {Stream<string>}
*/
this.email = Stream(this.attrs.email || '');
/**
* The value of the password input.
*
* @type {Function}
* @type {Stream<string>}
*/
this.password = Stream(this.attrs.password || '');
}
@@ -128,7 +128,9 @@ export default class SignUpModal extends Modal {
footer() {
return [
<p className="SignUpModal-logIn">{app.translator.trans('core.forum.sign_up.log_in_text', { a: <a onclick={this.logIn.bind(this)} /> })}</p>,
<p className="SignUpModal-logIn">
{app.translator.trans('core.forum.sign_up.log_in_text', { a: <Button class="Button Button--reset" onclick={this.logIn.bind(this)} /> })}
</p>,
];
}

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

@@ -63,12 +63,7 @@
&:active,
&.active,
.open > &.Dropdown-toggle {
.box-shadow(inset 0 3px 5px rgba(0, 0, 0, .125));
outline: none;
}
&:focus,
&.focus {
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
outline: none;
}
@@ -123,35 +118,57 @@
}
}
.Button--square {
padding-left: 9px;
padding-right: 9px;
}
.Button--rounded {
border-radius: 18px;
}
.Button--flat {
background: transparent;
border-radius: 18px;
}
.Button--link {
background: transparent !important;
&:hover {
background: transparent !important;
color: @link-color;
}
.Button--link {
color: @control-color;
background: unset !important;
&:active,
&.active,
&:hover,
&:focus,
&.focus,
.open > &.Dropdown-toggle {
background: transparent !important;
box-shadow: none;
color: @link-color;
box-shadow: none;
background: unset !important;
}
}
.Button--reset {
padding: 0;
margin: 0;
display: inline;
text-align: unset;
color: @link-color;
background: none;
vertical-align: unset;
white-space: unset;
line-height: unset;
.user-select(auto);
width: auto !important;
&:active,
&:hover,
&:focus,
.open > &.Dropdown-toggle {
color: @link-color;
background: unset !important;
box-shadow: none;
}
}
.Button--text {
background: transparent !important;
padding: 0;
@@ -167,6 +184,7 @@
box-shadow: none;
}
}
.Button--primary {
.Button--color(@body-bg, @primary-color, 'button-primary');
font-weight: bold;
@@ -177,9 +195,11 @@
display: none;
}
}
.Button--danger {
.Button--color(@control-danger-color, @control-danger-bg, 'control-danger');
}
.Button--more {
padding: 2px 4px;
line-height: 1;
@@ -188,6 +208,7 @@
margin: 0;
}
}
.Button--block {
display: block;
width: 100%;
@@ -199,6 +220,7 @@
margin-top: 5px;
}
}
// Little round icon buttons
.Button--icon {
width: 36px;
@@ -214,6 +236,7 @@
margin: 0;
}
}
.SessionDropdown .Dropdown-toggle {
border-radius: 18px;
@@ -222,16 +245,20 @@
.Avatar--size(24px);
}
}
.Button-icon {
margin-right: 7px;
}
.Button-icon,
.Button-caret {
font-size: 14px;
}
.Button-caret {
margin-left: 7px;
}
.Button-badge {
font-size: 12px;
font-weight: bold;

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,16 @@
.Search {
position: relative;
&-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 +81,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

@@ -7,11 +7,17 @@
font-size: 14px;
margin-right: 2px;
}
&:hover, &:focus {
&:hover,
&:focus {
text-decoration: none;
color: @link-color;
}
}
&-first,
&-last {
color: @control-color;
}
}
.Scrubber-scrollbar {
margin: 8px 0 8px 3px;
@@ -21,14 +27,20 @@
cursor: pointer;
.user-select(none);
}
.Scrubber-before, .Scrubber-after {
.Scrubber-before,
.Scrubber-after {
border-left: 1px solid @control-bg;
}
.Scrubber-unread {
position: absolute;
border-left: 1px solid lighten(@muted-color, 10%);
width: 100%;
background-image: linear-gradient(to right, @control-bg, fade(@control-bg, 0) 10px, fade(@control-bg, 0));
background-image: linear-gradient(
to right,
@control-bg,
fade(@control-bg, 0) 10px,
fade(@control-bg, 0)
);
display: flex;
align-items: center;
color: @muted-color;

View File

@@ -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
@@ -521,6 +523,7 @@ 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.
@@ -545,6 +548,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 +690,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

@@ -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

@@ -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

@@ -236,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;
}
/**
@@ -245,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

@@ -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

@@ -153,9 +153,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);
}
});
}

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,19 @@ class Extension implements Arrayable
return $icon;
}
public function getIconStyles(): string
{
$properties = $this->getIcon();
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

@@ -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

@@ -30,8 +30,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');
}
}

View File

@@ -34,8 +34,6 @@ class FulltextGambit implements GambitInterface
*/
private function getUserSearchSubQuery($searchValue)
{
$searchValue = $this->users->escapeLikeString($searchValue);
return $this->users
->query()
->select('id')

View File

@@ -102,8 +102,6 @@ class UserRepository
* @param string $string
* @param User|null $actor
* @return array
*
* @deprecated remove in 2.0 (no longer used since https://github.com/flarum/core/pull/1878)
*/
public function getIdsForUsername($string, User $actor = null)
{
@@ -137,10 +135,8 @@ class UserRepository
*
* @param string $string
* @return string
*
* @internal
*/
public function escapeLikeString($string)
private function escapeLikeString($string)
{
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $string);
}

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

@@ -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

@@ -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;