mirror of
https://github.com/flarum/core.git
synced 2025-08-15 04:44:08 +02:00
Compare commits
60 Commits
as/api-cli
...
dw/2.0-use
Author | SHA1 | Date | |
---|---|---|---|
|
6a57183525 | ||
|
2c801711bb | ||
|
69b1dc7103 | ||
|
6200ffef9b | ||
|
5e84490fd0 | ||
|
2b0d55632e | ||
|
f7a78d85e3 | ||
|
972411673f | ||
|
7ebf535b25 | ||
|
a661376d16 | ||
|
5a1bf08d3f | ||
|
a9b1a518a2 | ||
|
9416b1c150 | ||
|
87f67744a8 | ||
|
4add23a984 | ||
|
c52c0987fb | ||
|
60f0ef0bd5 | ||
|
82d67919bb | ||
|
713d95eb36 | ||
|
d053bb5496 | ||
|
05121b928a | ||
|
0a7e885eab | ||
|
a65488000c | ||
|
4146a4c578 | ||
|
3f2e25b35f | ||
|
2a86c25297 | ||
|
919c543cbc | ||
|
99112429f9 | ||
|
b4772e5399 | ||
|
2b47e90827 | ||
|
1c2465b2da | ||
|
a6717ee981 | ||
|
450ab61620 | ||
|
e2f01c040b | ||
|
1d15cff9ca | ||
|
88724bb4cb | ||
|
1637b90531 | ||
|
245d0d2550 | ||
|
5dd48e1b86 | ||
|
c1a8c6c190 | ||
|
c10a30bae9 | ||
|
b0bc021034 | ||
|
1b193196da | ||
|
f56fc11af9 | ||
|
ebdc232b11 | ||
|
eb0dd1f0d0 | ||
|
1aa61f1f01 | ||
|
e8153ccc79 | ||
|
55d8af44a2 | ||
|
4de5ad94f0 | ||
|
735583397c | ||
|
da94488f7b | ||
|
581d9517db | ||
|
3db724e0b3 | ||
|
71073b064a | ||
|
d82c093c0f | ||
|
c2a0cf8d04 | ||
|
1b77df12b6 | ||
|
d333d0b0e6 | ||
|
b5620e0549 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,2 +0,0 @@
|
||||
github: flarum
|
||||
open_collective: flarum
|
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
13
.github/SECURITY.md
vendored
@@ -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
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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_]
|
||||
|
||||
|
55
CHANGELOG.md
55
CHANGELOG.md
@@ -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
|
||||
|
@@ -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
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
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
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
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
1478
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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,
|
||||
|
@@ -38,6 +38,7 @@ export default class PermissionDropdown extends Dropdown {
|
||||
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.lazyDraw = true;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
|
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@@ -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;
|
||||
|
@@ -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');
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -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();
|
||||
};
|
||||
|
@@ -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(',');
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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}
|
||||
|
@@ -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,
|
||||
|
@@ -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',
|
||||
});
|
||||
|
||||
|
@@ -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`)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
@@ -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}
|
||||
|
@@ -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);
|
||||
|
@@ -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}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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
17
less/admin/NoJs.less
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -63,9 +63,6 @@
|
||||
&:hover {
|
||||
background: @control-bg;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
> a, > button {
|
||||
|
@@ -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;
|
||||
|
@@ -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
69
less/common/Table.less
Normal 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;
|
||||
}
|
||||
}
|
@@ -27,6 +27,7 @@
|
||||
@import "Placeholder";
|
||||
@import "Search";
|
||||
@import "Select";
|
||||
@import "Table";
|
||||
@import "TextEditor";
|
||||
@import "Tooltip";
|
||||
@import "ValidationError";
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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."
|
||||
|
@@ -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,
|
||||
];
|
||||
});
|
||||
|
||||
|
60
src/Admin/Content/Index.php
Normal file
60
src/Admin/Content/Index.php
Normal 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;
|
||||
}
|
||||
}
|
55
src/Admin/Controller/UpdateExtensionController.php
Normal file
55
src/Admin/Controller/UpdateExtensionController.php
Normal 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());
|
||||
}
|
||||
}
|
25
src/Admin/Middleware/DisableBrowserCache.php
Normal file
25
src/Admin/Middleware/DisableBrowserCache.php
Normal 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');
|
||||
}
|
||||
}
|
@@ -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)
|
||||
);
|
||||
};
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
51
src/Api/Controller/ShowGroupController.php
Normal file
51
src/Api/Controller/ShowGroupController.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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}',
|
||||
|
@@ -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';
|
||||
|
@@ -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
69
src/Extend/Theme.php
Normal 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;
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
37
src/Extension/Command/ToggleExtension.php
Normal file
37
src/Extension/Command/ToggleExtension.php
Normal 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;
|
||||
}
|
||||
}
|
41
src/Extension/Command/ToggleExtensionHandler.php
Normal file
41
src/Extension/Command/ToggleExtensionHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
*/
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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]
|
||||
];
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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 [
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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)));
|
||||
|
@@ -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' => ''
|
||||
]
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
40
src/Group/Filter/GroupFilterer.php
Normal file
40
src/Group/Filter/GroupFilterer.php
Normal 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);
|
||||
}
|
||||
}
|
26
src/Group/Filter/HiddenFilter.php
Normal file
26
src/Group/Filter/HiddenFilter.php
Normal 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);
|
||||
}
|
||||
}
|
@@ -21,7 +21,7 @@ class GroupRepository
|
||||
*/
|
||||
public function query()
|
||||
{
|
||||
return User::query();
|
||||
return Group::query();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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');
|
||||
|
@@ -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");
|
||||
|
@@ -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) {
|
||||
|
@@ -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.'
|
||||
];
|
||||
});
|
||||
}
|
||||
|
@@ -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
3
tests/fixtures/less/Imported.less
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.Imported {
|
||||
// ...
|
||||
}
|
1
tests/fixtures/less/dummy.less
vendored
Normal file
1
tests/fixtures/less/dummy.less
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.dummy_test_case{color:red}
|
5
tests/fixtures/less/forum.less
vendored
Normal file
5
tests/fixtures/less/forum.less
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
@import 'Imported';
|
||||
|
||||
.dummy {
|
||||
color: yellow;
|
||||
}
|
3
tests/fixtures/less/override_filesource.less
vendored
Normal file
3
tests/fixtures/less/override_filesource.less
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
color: orange;
|
||||
}
|
@@ -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 [
|
||||
|
126
tests/integration/api/groups/ShowTest.php
Normal file
126
tests/integration/api/groups/ShowTest.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
82
tests/integration/api/posts/ShowTest.php
Normal file
82
tests/integration/api/posts/ShowTest.php
Normal 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']);
|
||||
}
|
||||
}
|
93
tests/integration/extenders/FrontendPreloadTest.php
Normal file
93
tests/integration/extenders/FrontendPreloadTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
107
tests/integration/extenders/ThemeTest.php
Normal file
107
tests/integration/extenders/ThemeTest.php
Normal 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());
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
70
views/frontend/content/admin.blade.php
Normal file
70
views/frontend/content/admin.blade.php
Normal 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>
|
@@ -86,7 +86,6 @@
|
||||
.form-control:focus,
|
||||
.form-control.focus {
|
||||
border-color: {{ $primaryColor }};
|
||||
outline: none;
|
||||
}
|
||||
.errors {
|
||||
color: #d83e3e;
|
||||
|
@@ -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">
|
||||
|
Reference in New Issue
Block a user