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

Compare commits

..

27 Commits

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

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

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

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

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

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

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

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

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

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

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

[skip ci]
2021-10-13 18:51:06 +00:00
Braunson Yager
2a86c25297 Added ES6 local support for formatNumber helper as per #2951 (#3099) 2021-10-13 14:48:37 -04:00
Sergiy Petrov
919c543cbc Test against php 8.1 (#3102) 2021-10-13 14:48:03 -04:00
43 changed files with 731 additions and 86 deletions

View File

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

View File

@@ -1,9 +1,5 @@
# Changelog
## [1.1.1](https://github.com/flarum/core/compare/v1.1.0...v1.1.1)
### Fixed
- Performance issue with very large communities.
## [1.1.0](https://github.com/flarum/core/compare/v1.0.4...v1.1.0)

View File

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

4
js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

4
js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,12 @@
.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 {

View File

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

View File

@@ -115,7 +115,7 @@ core:
# These translations are used on default extension pages.
extension:
configure_scopes: Configure Scopes
confirm_uninstall: Uninstalling will remove all database entries and assets related to the extension. Are you sure you want to continue?
confirm_purge: Purging will remove all database entries and assets related to the extension. It will not uninstall the extension; that must be done via Composer. Are you sure you want to continue?
disabled: Disabled
enable_to_see: Enable the extension to view and change settings.
enabled: Enabled
@@ -130,7 +130,7 @@ core:
no_settings: This extension has no settings.
open_modal: Open Settings
permissions_title: Permissions
uninstall_button: Uninstall
purge_button: Purge
# These translations are used in the secondary header.
header:
@@ -516,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:
@@ -526,6 +527,7 @@ core:
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:

View File

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

View File

@@ -125,17 +125,6 @@ class ListDiscussionsController extends AbstractListController
$results = $results->getResults();
/*
* @TODO replace in 1.2 with proper implementation!!!
*/
if (in_array('tags.state', $include, true)) {
$results->load([
'tags.state' => function ($query) use ($actor) {
$query->where('user_id', $actor->id);
}
]);
}
$this->loadRelations($results, $include);
if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Group\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
class HiddenFilter implements FilterInterface
{
public function getFilterKey(): string
{
return 'hidden';
}
public function filter(FilterState $filterState, string $filterValue, bool $negate)
{
$filterState->getQuery()->where('is_hidden', $negate ? '!=' : '=', $filterValue);
}
}

View File

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

View File

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

View File

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

View File

@@ -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']);
@@ -69,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]);

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\posts;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class ShowTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->prepareDatabase([
'discussions' => [
['id' => 1, 'title' => 'Discussion with post', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1, 'is_private' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>valid</p></t>'],
['id' => 2, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<tMALFORMED'],
],
'users' => [
$this->normalUser(),
]
]);
}
/**
* @test
*/
public function properly_formatted_post_rendered_correctly()
{
$response = $this->send(
$this->request('GET', '/api/posts/1', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = (string) $response->getBody();
$this->assertJson($body);
$data = json_decode($body, true);
$this->assertEquals($data['data']['attributes']['contentHtml'], '<p>valid</p>');
}
/**
* @test
*/
public function malformed_post_caught_by_renderer()
{
$response = $this->send(
$this->request('GET', '/api/posts/2', [
'authenticatedAs' => 2,
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = (string) $response->getBody();
$this->assertJson($body);
$data = json_decode($body, true);
$this->assertEquals("Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.", $data['data']['attributes']['contentHtml']);
}
}

View File

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

View File

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