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

Compare commits

..

22 Commits

Author SHA1 Message Date
Alexander Skvortsov
984f751c71 Use process isolation for integration tests 2020-12-01 19:33:24 -05:00
flarum-bot
8830e9dd09 Bundled output for commit fe41bc1fdc [skip ci] 2020-12-01 16:22:59 +00:00
Alexander Skvortsov
fe41bc1fdc Remove Deprecated Beta14 Code (#2428) 2020-12-01 11:21:28 -05:00
Nina Pypchenko
5a763050a6 DRY up image uploading code (#2477) 2020-12-01 10:42:05 -05:00
Sami Mazouz
8c813bc340 ApiSerializer Extender (#2438) 2020-11-30 19:24:50 -05:00
flarum-bot
f67dee0a9e Bundled output for commit f968420216 [skip ci] 2020-11-30 19:02:41 +00:00
Alexander Skvortsov
f968420216 Don't use browser scroll restore in DiscussionPage (#2476)
Although native browser scroll restorations have become quite powerful, it interferes with Flarum's PostStream, so if we're on a DiscussionPage, we use manual scroll restoration.
2020-11-30 14:01:08 -05:00
flarum-bot
d5e124b4a2 Bundled output for commit 09e2736cbc [skip ci] 2020-11-29 23:34:50 +00:00
Alexander Skvortsov
09e2736cbc Fix goToIndex to visible end
In the PostStream, `this.visibleEnd` represents the index of the last post + 1, but `loadNearIndex` treated it as if it was the index of the last post. This means that executing `goToIndex` on the post stream's current `this.visiblePost` didn't load new posts, and as a result, the requested scrolling did not occur.
2020-11-29 18:33:29 -05:00
flarum-bot
ddb3d3edb0 Bundled output for commit 28d56f5fc8 [skip ci] 2020-11-29 22:47:21 +00:00
Alexander Skvortsov
28d56f5fc8 Merge pull request #2465 from flarum/0.1.0-beta.14.1 2020-11-29 17:45:58 -05:00
Alexander Skvortsov
9b4012bbb5 Reset dist js 2020-11-29 17:41:16 -05:00
Alexander Skvortsov
1a5e4d454e Move floodgate to middleware, add extender + integration tests (#2170) 2020-11-29 17:13:22 -05:00
sl-kr
387b4fd315 update a user's comment count if deleting a discussion (#2472) 2020-11-29 17:11:05 -05:00
flarum-bot
66482c2815 Bundled output for commit 277a5c3fac [skip ci] 2020-11-26 22:54:38 +00:00
Mohammad Reza
277a5c3fac Clear error alerts in change email modal on success (#2467) 2020-11-26 17:53:38 -05:00
Nina Pypchenko
286d8dec5b Update tsconfig file to include .tsx files (#2457) 2020-11-26 12:00:13 -05:00
Daniël Klabbers
967cd0e3ca update version constant for beta 14.1 2020-11-02 13:53:20 +01:00
Daniël Klabbers
b79152b977 bundled output for js changes beta 14.1 2020-11-02 11:53:27 +01:00
Daniël Klabbers
ace624db66 changelog for v0.1.0-beta.14.1 2020-11-02 11:51:24 +01:00
Alexander Skvortsov
9b9f2c4bb7 Fix exiting composer while in fullscreen mode. 2020-10-30 20:44:52 -04:00
Alexander Skvortsov
8b1de457bf Fix broken page title logic on subpath installs
The base path needs to be accounted for when calculating whether we're on the default route.
2020-10-30 14:18:09 -04:00
76 changed files with 1372 additions and 1224 deletions

View File

@@ -1,5 +1,15 @@
# Changelog
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
### Fixed
- SuperTextarea component is not exported.
- Symfony dependencies do not match those depended on by Laravel (#2407)
- Scripts from textformatter aren't executed (#2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added

6
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -270,7 +270,7 @@ export default class Application {
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}

View File

@@ -1,8 +1,5 @@
import * as Mithril from 'mithril';
let deprecatedPropsWarned = false;
let deprecatedInitPropsWarned = false;
export interface ComponentAttrs extends Mithril.Attributes {}
/**
@@ -131,38 +128,5 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {
// Deprecated, part of Mithril 2 BC layer
if ('initProps' in this && !deprecatedInitPropsWarned) {
deprecatedInitPropsWarned = true;
console.warn('initProps is deprecated, please use initAttrs instead.');
(this as any).initProps(attrs);
}
}
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*
* @deprecated, use attrs instead.
*/
get props() {
if (!deprecatedPropsWarned) {
deprecatedPropsWarned = true;
console.warn('this.props is deprecated, please use this.attrs instead.');
}
return this.attrs;
}
set props(props) {
if (!deprecatedPropsWarned) {
deprecatedPropsWarned = true;
console.warn('this.props is deprecated, please use this.attrs instead.');
}
this.attrs = props;
}
// END DEPRECATED MITHRIL 2 BC LAYER
protected static initAttrs<T>(attrs: T): void {}
}

View File

@@ -29,6 +29,13 @@ export default class Page extends Component {
* @type {Boolean}
*/
this.scrollTopOnCreate = true;
/**
* Whether the browser should restore scroll state on refreshes.
*
* @type {Boolean}
*/
this.useBrowserScrollRestoration = true;
}
oncreate(vnode) {
@@ -41,6 +48,10 @@ export default class Page extends Component {
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
if ('scrollRestoration' in history) {
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
}
}
onremove() {

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
import 'expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';

View File

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

View File

@@ -1,9 +1,3 @@
import withAttr from './withAttr';
import Stream from './Stream';
let deprecatedMPropWarned = false;
let deprecatedMWithAttrWarned = false;
export default function patchMithril(global) {
const defaultMithril = global.m;
@@ -22,23 +16,5 @@ export default function patchMithril(global) {
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
modifiedMithril.prop = function (...args) {
if (!deprecatedMPropWarned) {
deprecatedMPropWarned = true;
console.warn('m.prop() is deprecated, please use the Stream util (flarum/utils/Streams) instead.');
}
return Stream.bind(this)(...args);
};
modifiedMithril.withAttr = function (...args) {
if (!deprecatedMWithAttrWarned) {
deprecatedMWithAttrWarned = true;
console.warn("m.withAttr() is deprecated, please use flarum's withAttr util (flarum/utils/withAttr) instead.");
}
return withAttr.bind(this)(...args);
};
// END DEPRECATED MITHRIL 2 BC LAYER
global.m = modifiedMithril;
}

View File

@@ -90,11 +90,6 @@ export default class ForumApplication extends Application {
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({}, this);
/**
* @deprecated beta 14, remove in beta 15.
*/
this.cache.discussionList = this.discussions;
}
/**

View File

@@ -118,7 +118,10 @@ export default class ChangeEmailModal extends Modal {
meta: { password: this.password() },
}
)
.then(() => (this.success = true))
.then(() => {
this.success = true;
this.alertAttrs = null;
})
.catch(() => {})
.then(this.loaded.bind(this));
}

View File

@@ -199,7 +199,7 @@ export default class Composer extends Component {
*/
animatePositionChange() {
// When exiting full-screen mode: focus content
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {
this.focus();
return;
}

View File

@@ -44,12 +44,6 @@ export default class ComposerBody extends Component {
}
this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.composer.fields.content;
this.editor = this.composer;
}
view() {

View File

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

View File

@@ -18,6 +18,8 @@ export default class DiscussionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.useBrowserScrollRestoration = false;
/**
* The discussion that is being viewed.
*
@@ -107,7 +109,7 @@ export default class DiscussionPage extends Page {
} else {
const params = this.requestParams();
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
}
m.redraw();
@@ -121,7 +123,6 @@ export default class DiscussionPage extends Page {
*/
requestParams() {
return {
bySlug: true,
page: { near: this.near },
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,11 +34,6 @@ class ComposerState {
this.editor = null;
this.clear();
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.component = this;
}
/**
@@ -77,12 +72,6 @@ class ComposerState {
this.fields = {
content: Stream(''),
};
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.fields.content;
this.value = this.fields.content;
}
/**

View File

@@ -172,7 +172,7 @@ class PostStreamState {
* @return {Promise}
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
if (index >= this.visibleStart && index < this.visibleEnd) {
return Promise.resolve();
}

View File

@@ -1,5 +1,5 @@
{
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"files": ["shims.d.ts"],
"compilerOptions": {
"allowUmdGlobalAccess": true,

View File

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

View File

@@ -42,6 +42,20 @@ class ApiServiceProvider extends AbstractServiceProvider
return $routes;
});
$this->app->singleton('flarum.api.throttlers', function () {
return [
'bypassThrottlingAttribute' => function ($request) {
if ($request->getAttribute('bypassThrottling')) {
return false;
}
}
];
});
$this->app->bind(Middleware\ThrottleApi::class, function ($app) {
return new Middleware\ThrottleApi($app->make('flarum.api.throttlers'));
});
$this->app->singleton('flarum.api.middleware', function () {
return [
'flarum.api.error_handler',
@@ -53,7 +67,8 @@ class ApiServiceProvider extends AbstractServiceProvider
HttpMiddleware\AuthenticateWithHeader::class,
HttpMiddleware\SetLocale::class,
'flarum.api.route_resolver',
HttpMiddleware\CheckCsrfToken::class
HttpMiddleware\CheckCsrfToken::class,
Middleware\ThrottleApi::class
];
});

View File

@@ -64,6 +64,9 @@ class CreateDiscussionController extends AbstractCreateController
$actor = $request->getAttribute('actor');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}

View File

@@ -65,6 +65,9 @@ class CreatePostController extends AbstractCreateController
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}

View File

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

View File

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

View File

@@ -9,69 +9,36 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use Intervention\Image\ImageManager;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Psr\Http\Message\UploadedFileInterface;
class UploadFaviconController extends ShowForumController
class UploadFaviconController extends UploadImageController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $filePathSettingKey = 'favicon_path';
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
protected $filenamePrefix = 'favicon';
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
protected function makeImage(UploadedFileInterface $file): Image
{
$request->getAttribute('actor')->assertAdmin();
$this->fileExtension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
$file = Arr::get($request->getUploadedFiles(), 'favicon');
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
if ($extension === 'ico') {
$image = $file->getStream();
if ($this->fileExtension === 'ico') {
$encodedImage = $file->getStream();
} else {
$manager = new ImageManager;
$manager = new ImageManager();
$image = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$encodedImage = $manager->make($file->getStream())->resize(64, 64, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})->encode('png');
$extension = 'png';
$this->fileExtension = 'png';
}
if (($path = $this->settings->get('favicon_path')) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = 'favicon-'.Str::lower(Str::random(8)).'.'.$extension;
$this->uploadDir->write($uploadName, $image);
$this->settings->set('favicon_path', $uploadName);
return parent::data($request, $document);
return $encodedImage;
}
}

View File

@@ -0,0 +1,87 @@
<?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\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Tobscure\JsonApi\Document;
abstract class UploadImageController extends ShowForumController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @var string
*/
protected $fileExtension = 'png';
/**
* @var string
*/
protected $filePathSettingKey = '';
/**
* @var string
*/
protected $filenamePrefix = '';
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
{
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), $this->filenamePrefix);
$encodedImage = $this->makeImage($file);
if (($path = $this->settings->get($this->filePathSettingKey)) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = $this->filenamePrefix.'-'.Str::lower(Str::random(8)).'.'.$this->fileExtension;
$this->uploadDir->write($uploadName, $encodedImage);
$this->settings->set($this->filePathSettingKey, $uploadName);
return parent::data($request, $document);
}
/**
* @param UploadedFileInterface $file
* @return Image
*/
abstract protected function makeImage(UploadedFileInterface $file): Image;
}

View File

@@ -9,61 +9,27 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use Intervention\Image\ImageManager;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Psr\Http\Message\UploadedFileInterface;
class UploadLogoController extends ShowForumController
class UploadLogoController extends UploadImageController
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $filePathSettingKey = 'logo_path';
/**
* @var FilesystemInterface
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
}
protected $filenamePrefix = 'logo';
/**
* {@inheritdoc}
*/
public function data(ServerRequestInterface $request, Document $document)
protected function makeImage(UploadedFileInterface $file): Image
{
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), 'logo');
$manager = new ImageManager;
$manager = new ImageManager();
$encodedImage = $manager->make($file->getStream())->heighten(60, function ($constraint) {
$constraint->upsize();
})->encode('png');
if (($path = $this->settings->get('logo_path')) && $this->uploadDir->has($path)) {
$this->uploadDir->delete($path);
}
$uploadName = 'logo-'.Str::lower(Str::random(8)).'.png';
$this->uploadDir->write($uploadName, $encodedImage);
$this->settings->set('logo_path', $uploadName);
return parent::data($request, $document);
return $encodedImage;
}
}

View File

@@ -17,6 +17,8 @@ use Flarum\Api\Serializer\AbstractSerializer;
*
* This event is fired when a serializer is constructing an array of resource
* attributes for API output.
*
* @deprecated in beta 15, removed in beta 16
*/
class Serializing
{

View File

@@ -0,0 +1,57 @@
<?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\Middleware;
use Flarum\Post\Exception\FloodingException;
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 ThrottleApi implements Middleware
{
protected $throttlers;
public function __construct(array $throttlers)
{
$this->throttlers = $throttlers;
}
public function process(Request $request, Handler $handler): Response
{
if ($this->throttle($request)) {
throw new FloodingException;
}
return $handler->handle($request);
}
/**
* @return bool
*/
public function throttle(Request $request): bool
{
$throttle = false;
foreach ($this->throttlers as $throttler) {
$result = $throttler($request);
// Explicitly returning false overrides all throttling.
// Explicitly returning true marks the request to be throttled.
// Anything else is ignored.
if ($result === false) {
return false;
} elseif ($result === true) {
$throttle = true;
}
}
return $throttle;
}
}

View File

@@ -16,6 +16,7 @@ use Flarum\Event\GetApiRelationship;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use LogicException;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -47,6 +48,16 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected static $container;
/**
* @var callable[]
*/
protected static $mutators = [];
/**
* @var array
*/
protected static $customRelations = [];
/**
* @return Request
*/
@@ -83,6 +94,18 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
$attributes = $this->getDefaultAttributes($model);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$mutators[$class])) {
foreach (static::$mutators[$class] as $callback) {
$attributes = array_merge(
$attributes,
$callback($this, $model, $attributes)
);
}
}
}
// Deprecated in beta 15, removed in beta 16
static::$dispatcher->dispatch(
new Serializing($this, $model, $attributes)
);
@@ -102,7 +125,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
* @param DateTime|null $date
* @return string|null
*/
protected function formatDate(DateTime $date = null)
public function formatDate(DateTime $date = null)
{
if ($date) {
return $date->format(DateTime::RFC3339);
@@ -130,10 +153,20 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected function getCustomRelationship($model, $name)
{
// Deprecated in beta 15, removed in beta 16
$relationship = static::$dispatcher->until(
new GetApiRelationship($this, $name, $model)
);
foreach (array_merge([static::class], class_parents($this)) as $class) {
$callback = Arr::get(static::$customRelations, "$class.$name");
if (is_callable($callback)) {
$relationship = $callback($this, $model);
break;
}
}
if ($relationship && ! ($relationship instanceof Relationship)) {
throw new LogicException(
'GetApiRelationship handler must return an instance of '.Relationship::class
@@ -280,4 +313,27 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
{
static::$container = $container;
}
/**
* @param string $serializerClass
* @param callable $mutator
*/
public static function addMutator(string $serializerClass, callable $mutator)
{
if (! isset(static::$mutators[$serializerClass])) {
static::$mutators[$serializerClass] = [];
}
static::$mutators[$serializerClass][] = $mutator;
}
/**
* @param string $serializerClass
* @param string $relation
* @param callable $callback
*/
public static function setRelationship(string $serializerClass, string $relation, callable $callback)
{
static::$customRelations[$serializerClass][$relation] = $callback;
}
}

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion;
use Flarum\Database\AbstractModel;
use Flarum\Http\SlugDriverInterface;
use Flarum\User\User;
class IdWithTransliteratedSlugDriver implements SlugDriverInterface
{
/**
* @var DiscussionRepository
*/
protected $discussions;
public function __construct(DiscussionRepository $discussions)
{
$this->discussions = $discussions;
}
public function toSlug(AbstractModel $instance): string
{
return $instance->id.(trim($instance->slug) ? '-'.$instance->slug : '');
}
public function fromSlug(string $slug, User $actor): AbstractModel
{
if (strpos($slug, '-')) {
$slug_array = explode('-', $slug);
$slug = $slug_array[0];
}
return $this->discussions->findOrFail($slug, $actor);
}
}

View File

@@ -21,6 +21,8 @@ use Flarum\Api\Serializer\AbstractSerializer;
* @see AbstractSerializer::hasOne()
* @see AbstractSerializer::hasMany()
* @see https://github.com/tobscure/json-api
*
* @deprecated in beta 15, removed in beta 16
*/
class GetApiRelationship
{

View File

@@ -1,39 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15. Use the User extender instead.
* The `PrepareUserGroups` event.
*/
class PrepareUserGroups
{
/**
* @var User
*/
public $user;
/**
* @var array
*/
public $groupIds;
/**
* @param User $user
* @param array $groupIds
*/
public function __construct(User $user, array &$groupIds)
{
$this->user = $user;
$this->groupIds = &$groupIds;
}
}

View File

@@ -0,0 +1,162 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class ApiSerializer implements ExtenderInterface
{
private $serializerClass;
private $attributes = [];
private $mutators = [];
private $relationships = [];
/**
* @param string $serializerClass The ::class attribute of the serializer you are modifying.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
*/
public function __construct(string $serializerClass)
{
$this->serializerClass = $serializerClass;
}
/**
* @param string $name: The name of the attribute.
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
* - $attributes: An array of existing attributes.
*
* The callable should return:
* - The value of the attribute.
*
* @return self
*/
public function attribute(string $name, $callback)
{
$this->attributes[$name] = $callback;
return $this;
}
/**
* Add to or modify the attributes array of this serializer.
*
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
* - $attributes: An array of existing attributes.
*
* The callable should return:
* - An array of additional attributes to merge with the existing array.
* Or a modified $attributes array.
*
* @return self
*/
public function mutate($callback)
{
$this->mutators[] = $callback;
return $this;
}
/**
* Establish a simple hasOne relationship from this serializer to another serializer.
* This represents a one-to-one relationship.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param string $serializerClass: The ::class attribute the serializer that handles this relation.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @return self
*/
public function hasOne(string $name, string $serializerClass)
{
return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) {
return $serializer->hasOne($model, $serializerClass, $name);
});
}
/**
* Establish a simple hasMany relationship from this serializer to another serializer.
* This represents a one-to-many relationship.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param string $serializerClass: The ::class attribute the serializer that handles this relation.
* This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer.
* @return self
*/
public function hasMany(string $name, string $serializerClass)
{
return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) {
return $serializer->hasMany($model, $serializerClass, $name);
});
}
/**
* Add a relationship from this serializer to another serializer.
*
* @param string $name: The name of the relation. Has to be unique from other relation names.
* The relation has to exist in the model handled by this serializer.
* @param callable|string $callback
*
* The callable can be a closure or an invokable class, and should accept:
* - $serializer: An instance of this serializer.
* - $model: An instance of the model being serialized.
*
* The callable should return:
* - $relationship: An instance of \Tobscure\JsonApi\Relationship.
*
* @return self
*/
public function relationship(string $name, $callback)
{
$this->relationships[$this->serializerClass][$name] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if (! empty($this->attributes)) {
$this->mutators[] = function ($serializer, $model, $attributes) use ($container) {
foreach ($this->attributes as $attributeName => $callback) {
$callback = ContainerUtil::wrapCallback($callback, $container);
$attributes[$attributeName] = $callback($serializer, $model, $attributes);
}
return $attributes;
};
}
foreach ($this->mutators as $mutator) {
$mutator = ContainerUtil::wrapCallback($mutator, $container);
AbstractSerializer::addMutator($this->serializerClass, $mutator);
}
foreach ($this->relationships as $serializerClass => $relationships) {
foreach ($relationships as $relation => $callback) {
$callback = ContainerUtil::wrapCallback($callback, $container);
AbstractSerializer::setRelationship($serializerClass, $relation, $callback);
}
}
}
}

View File

@@ -1,84 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
class ModelUrl implements ExtenderInterface
{
private $modelClass;
private $slugDrivers = [];
private $urlGenerator;
/**
* @param string $modelClass The ::class attribute of the model you are modifying.
* This model should extend from \Flarum\Database\AbstractModel.
*/
public function __construct(string $modelClass)
{
$this->modelClass = $modelClass;
}
/**
* Add a slug driver.
*
* @param string $identifier Identifier for slug driver.
* @param string $driver ::class attribute of driver class, which must implement Flarum\Http\SlugDriverInterface
* @return self
*/
public function addSlugDriver(string $identifier, string $driver)
{
$this->slugDrivers[$identifier] = $driver;
return $this;
}
/**
* Overrides the url generator for this resource.
*
* @param callable|string $callback
* @return self
*
* The callable can be a closure or invokable class, and should accept:
* - \Flarum\Http\UrlGenerator $urlGenerator: an instance of the URL generator.
* - \Flarum\Database\AbstractModel $instance: The model instance for which the url is being generated
* - ...$args: Any additional optional arguments
*
* The callable should return:
* - string $url: A valid URL pointing to the resource instance
*/
public function setUrlGenerator($callback)
{
$this->urlGenerator = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if ($this->urlGenerator) {
$container->extend('flarum.http.resourceUrlGenerators', function ($existingUrlGenerators) use ($container) {
$existingUrlGenerators[$this->modelClass] = ContainerUtil::wrapCallback($this->urlGenerator, $container);
return $existingUrlGenerators;
});
}
if ($this->slugDrivers) {
$container->extend('flarum.http.slugDrivers', function ($existingDrivers) {
$existingDrivers[$this->modelClass] = array_merge(Arr::get($existingDrivers, $this->modelClass, []), $this->slugDrivers);
return $existingDrivers;
});
}
}
}

View File

@@ -0,0 +1,74 @@
<?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\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class ThrottleApi implements ExtenderInterface
{
private $setThrottlers = [];
private $removeThrottlers = [];
/**
* Add a new throttler (or override one with the same name).
*
* @param string $name: The name of the throttler.
* @param string|callable $callback
*
* The callable can be a closure or invokable class, and should accept:
* - $request: The current `\Psr\Http\Message\ServerRequestInterface` request object.
* `$request->getAttribute('actor')` can be used to get the current user.
* `$request->getAttribute('routeName')` can be used to get the current route.
* Please note that every throttler runs by default on every route.
* If you only want to throttle certain routes, you'll need to check for that inside your logic.
*
* The callable should return one of:
* - `false`: This marks the request as NOT to be throttled. It overrides all other throttlers
* - `true`: This marks the request as to be throttled.
* All other outputs will be ignored.
*
* @return self
*/
public function set(string $name, $callback)
{
$this->setThrottlers[$name] = $callback;
return $this;
}
/**
* Remove a throttler registered with this name.
*
* @param string $name: The name of the throttler to remove.
*
* @return self
*/
public function remove(string $name)
{
$this->removeThrottlers[] = $name;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.api.throttlers', function ($throttlers) use ($container) {
$throttlers = array_diff_key($throttlers, array_flip($this->removeThrottlers));
foreach ($this->setThrottlers as $name => $throttler) {
$throttlers[$name] = ContainerUtil::wrapCallback($throttler, $container);
}
return $throttlers;
});
}
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ class Application
*
* @var string
*/
const VERSION = '0.1.0-beta.14';
const VERSION = '0.1.0-beta.14.1';
/**
* The IoC container for the Flarum application.
@@ -153,50 +153,6 @@ class Application
$this->register(new EventServiceProvider($this->container));
}
/**
* Get the base path of the Laravel installation.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function basePath()
{
return $this->paths->base;
}
/**
* Get the path to the public / web directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function publicPath()
{
return $this->paths->public;
}
/**
* Get the path to the storage directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function storagePath()
{
return $this->paths->storage;
}
/**
* Get the path to the vendor directory where dependencies are installed.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function vendorPath()
{
return $this->paths->vendor;
}
/**
* Register a service provider with the application.
*

View File

@@ -9,14 +9,7 @@
namespace Flarum\Http;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\IdWithTransliteratedSlugDriver;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Application;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Flarum\User\UsernameSlugDriver;
class HttpServiceProvider extends AbstractServiceProvider
{
@@ -32,65 +25,5 @@ class HttpServiceProvider extends AbstractServiceProvider
$this->app->bind(Middleware\CheckCsrfToken::class, function ($app) {
return new Middleware\CheckCsrfToken($app->make('flarum.http.csrfExemptPaths'));
});
$this->app->singleton('flarum.http.slugDrivers', function () {
return [
Discussion::class => [
'default' => IdWithTransliteratedSlugDriver::class
],
User::class => [
'default' => UsernameSlugDriver::class
],
];
});
$this->app->singleton('flarum.http.selectedSlugDrivers', function () {
$settings = $this->app->make(SettingsRepositoryInterface::class);
$compiledDrivers = [];
foreach ($this->app->make('flarum.http.slugDrivers') as $resourceClass => $resourceDrivers) {
$driverKey = $settings->get("slug_driver_$resourceClass", 'default');
$driverClass = array_key_exists($driverKey, $resourceDrivers) ? $resourceDrivers[$driverKey] : $resourceDrivers['default'];
$compiledDrivers[$resourceClass] = $this->app->make($driverClass);
}
return $compiledDrivers;
});
$this->app->singleton('flarum.http.resourceUrlGenerators', function () {
$slugManager = $this->app->make(SlugManager::class);
return [
Discussion::class => function (UrlGenerator $urlGenerator, Discussion $discussion) use ($slugManager) {
return $urlGenerator->to('forum')->route('discussion', [
'id' => $slugManager->forResource(Discussion::class)->toSlug($discussion)
]);
},
Post::class => function (UrlGenerator $urlGenerator, Post $post) use ($slugManager) {
return $urlGenerator->to('forum')->route('post', [
'id' => $slugManager->forResource(Discussion::class)->toSlug($post->discussion),
'near' => $post->id,
]);
},
User::class => function (UrlGenerator $urlGenerator, User $user) use ($slugManager) {
return $urlGenerator->to('forum')->route('user', [
'username' => $slugManager->forResource(User::class)->toSlug($user)
]);
},
];
});
$this->app->bind(SlugManager::class, function () {
return new SlugManager($this->app->make('flarum.http.selectedSlugDrivers'));
});
$this->app->bind(UrlGenerator::class, function () {
return new UrlGenerator(
$this->app->make(Application::class),
$this->app->make('flarum.http.resourceUrlGenerators')
);
});
}
}

View File

@@ -38,7 +38,7 @@ class AuthenticateWithHeader implements Middleware
$actor = $key->user ?? $this->getUser($userId);
$request = $request->withAttribute('apiKey', $key);
$request = $request->withAttribute('bypassFloodgate', true);
$request = $request->withAttribute('bypassThrottling', true);
} elseif ($token = AccessToken::find($id)) {
$token->touch();

View File

@@ -1,20 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http;
use Flarum\Database\AbstractModel;
use Flarum\User\User;
interface SlugDriverInterface
{
public function toSlug(AbstractModel $instance): string;
public function fromSlug(string $slug, User $actor): AbstractModel;
}

View File

@@ -1,27 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Http;
use Illuminate\Support\Arr;
class SlugManager
{
protected $drivers = [];
public function __construct(array $drivers)
{
$this->drivers = $drivers;
}
public function forResource(string $resourceName): SlugDriverInterface
{
return Arr::get($this->drivers, $resourceName, null);
}
}

View File

@@ -9,7 +9,6 @@
namespace Flarum\Http;
use Flarum\Database\AbstractModel;
use Flarum\Foundation\Application;
class UrlGenerator
@@ -24,18 +23,12 @@ class UrlGenerator
*/
protected $app;
/**
* @var array
*/
protected $resourceUrlGenerators;
/**
* @param Application $app
*/
public function __construct(Application $app, array $resourceUrlGenerators)
public function __construct(Application $app)
{
$this->app = $app;
$this->resourceUrlGenerators = $resourceUrlGenerators;
}
/**
@@ -66,22 +59,4 @@ class UrlGenerator
{
return $this->routes[$collection];
}
/**
* Generate a URL to an instance of a resource.
*
* @param AbstractModel $instance
* @param $args
* @return string
*/
public function toResource(AbstractModel $instance, ...$args): string
{
foreach (array_merge(array_reverse(class_parents($instance)), [get_class($instance)]) as $class) {
$callback = $this->resourceUrlGenerators[$class];
if ($callback) {
return $callback($instance, ...$args);
}
}
}
}

View File

@@ -25,7 +25,5 @@ interface MailableInterface
*
* @return string
*/
// TODO: This is temporarily commented out to avoid BC breaks between beta 13 and beta 14.
// It should be uncommented before beta 15.
// public function getEmailSubject(TranslatorInterface $translator);
public function getEmailSubject(TranslatorInterface $translator);
}

View File

@@ -15,6 +15,9 @@ use Flarum\Post\Exception\FloodingException;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
/**
* @deprecated beta 14, removed beta 15 in favor of Floodgate middleware
*/
class Floodgate
{
/**

View File

@@ -9,11 +9,38 @@
namespace Flarum\Post;
use DateTime;
use Flarum\Event\ConfigurePostTypes;
use Flarum\Foundation\AbstractServiceProvider;
class PostServiceProvider extends AbstractServiceProvider
{
/**
* {@inheritdoc}
*/
public function register()
{
$this->app->extend('flarum.api.throttlers', function ($throttlers) {
$throttlers['postTimeout'] = function ($request) {
if (! in_array($request->getAttribute('routeName'), ['discussions.create', 'posts.create'])) {
return;
}
$actor = $request->getAttribute('actor');
if ($actor->can('postWithoutThrottle')) {
return false;
}
if (Post::where('user_id', $actor->id)->where('created_at', '>=', new DateTime('-10 seconds'))->exists()) {
return true;
}
};
return $throttlers;
});
}
/**
* {@inheritdoc}
*/

View File

@@ -1,72 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\Exception\PermissionDeniedException;
/**
* @deprecated beta 14, remove beta 15. Please use direct methods of the User class instead. E.g. $actor->assertCan($ability);
*/
trait AssertPermissionTrait
{
/**
* Ensure the current user is allowed to do something.
*
* If the condition is not met, an exception will be thrown that signals the
* lack of permissions. This is about *authorization*, i.e. retrying such a
* request / operation without a change in permissions (or using another
* user account) is pointless.
*
* @param bool $condition
* @throws PermissionDeniedException
*/
protected function assertPermission($condition)
{
if (! $condition) {
throw new PermissionDeniedException;
}
}
/**
* Ensure the given actor is authenticated.
*
* This will throw an exception for guest users, signaling that
* *authorization* failed. Thus, they could retry the operation after
* logging in (or using other means of authentication).
*
* @param User $actor
* @throws NotAuthenticatedException
*/
protected function assertRegistered(User $actor)
{
$actor->assertRegistered();
}
/**
* @param User $actor
* @param string $ability
* @param mixed $arguments
* @throws PermissionDeniedException
*/
protected function assertCan(User $actor, $ability, $arguments = [])
{
$actor->assertCan($ability, $arguments);
}
/**
* @param User $actor
* @throws PermissionDeniedException
*/
protected function assertAdmin(User $actor)
{
$actor->assertCan('administrate');
}
}

View File

@@ -1,31 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15.
*/
class GetDisplayName
{
/**
* @var User
*/
public $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
}

View File

@@ -15,7 +15,6 @@ use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Discussion\Discussion;
use Flarum\Event\ConfigureUserPreferences;
use Flarum\Event\PrepareUserGroups;
use Flarum\Foundation\EventGeneratorTrait;
use Flarum\Group\Group;
use Flarum\Group\Permission;
@@ -30,7 +29,6 @@ use Flarum\User\Event\CheckingPassword;
use Flarum\User\Event\Deleted;
use Flarum\User\Event\EmailChanged;
use Flarum\User\Event\EmailChangeRequested;
use Flarum\User\Event\GetDisplayName;
use Flarum\User\Event\PasswordChanged;
use Flarum\User\Event\Registered;
use Flarum\User\Event\Renamed;
@@ -327,8 +325,7 @@ class User extends AbstractModel
*/
public function getDisplayNameAttribute()
{
// Event is deprecated in beta 14, remove in beta 15.
return static::$dispatcher->until(new GetDisplayName($this)) ?: static::$displayNameDriver->displayName($this);
return static::$displayNameDriver->displayName($this);
}
/**
@@ -722,9 +719,6 @@ class User extends AbstractModel
$groupIds = array_merge($groupIds, [Group::MEMBER_ID], $this->groups->pluck('id')->all());
}
/** @deprecated in beta 14, remove in beta 15 */
event(new PrepareUserGroups($this, $groupIds));
foreach (static::$groupProcessors as $processor) {
$groupIds = $processor($this, $groupIds);
}

View File

@@ -14,7 +14,6 @@ use Flarum\Discussion\Event\Deleted as DiscussionDeleted;
use Flarum\Discussion\Event\Started;
use Flarum\Post\Event\Deleted as PostDeleted;
use Flarum\Post\Event\Posted;
use Flarum\Post\Post;
use Illuminate\Contracts\Events\Dispatcher;
class UserMetadataUpdater
@@ -35,7 +34,7 @@ class UserMetadataUpdater
*/
public function whenPostWasPosted(Posted $event)
{
$this->updateCommentsCount($event->post);
$this->updateCommentsCount($event->post->user);
}
/**
@@ -43,7 +42,7 @@ class UserMetadataUpdater
*/
public function whenPostWasDeleted(PostDeleted $event)
{
$this->updateCommentsCount($event->post);
$this->updateCommentsCount($event->post->user);
}
/**
@@ -60,12 +59,14 @@ class UserMetadataUpdater
public function whenDiscussionWasDeleted(DiscussionDeleted $event)
{
$this->updateDiscussionsCount($event->discussion);
$this->updateCommentsCount($event->discussion->user);
}
private function updateCommentsCount(Post $post)
/**
* @param \Flarum\User\User $user
*/
private function updateCommentsCount(User $user)
{
$user = $post->user;
if ($user && $user->exists) {
$user->refreshCommentCount()->save();
}

View File

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

View File

@@ -1,36 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User;
use Flarum\Database\AbstractModel;
use Flarum\Http\SlugDriverInterface;
class UsernameSlugDriver implements SlugDriverInterface
{
/**
* @var UserRepository
*/
protected $users;
public function __construct(UserRepository $users)
{
$this->users = $users;
}
public function toSlug(AbstractModel $instance): string
{
return $instance->username;
}
public function fromSlug(string $slug, User $actor): AbstractModel
{
return $this->users->findOrFailByUsername($slug, $actor);
}
}

View File

@@ -7,7 +7,6 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Foundation\Paths;
use Illuminate\Container\Container;
if (! function_exists('app')) {
@@ -28,48 +27,6 @@ if (! function_exists('app')) {
}
}
if (! function_exists('base_path')) {
/**
* Get the path to the base of the install.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function base_path($path = '')
{
return app(Paths::class)->base.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('public_path')) {
/**
* Get the path to the public folder.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function public_path($path = '')
{
return app(Paths::class)->public.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('storage_path')) {
/**
* Get the path to the storage folder.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function storage_path($path = '')
{
return app(Paths::class)->storage.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('event')) {
/**
* Fire an event and call the listeners.

View File

@@ -28,6 +28,9 @@ class WithApiKeyTest extends TestCase
$this->adminUser(),
$this->normalUser(),
],
'group_permission' => [
['permission' => 'viewUserList', 'group_id' => 3]
],
'api_keys' => [],
]);
}

View File

@@ -27,13 +27,20 @@ class CreateTest extends TestCase
'posts' => [],
'users' => [
$this->adminUser(),
$this->normalUser(),
],
'groups' => [
$this->adminGroup(),
$this->memberGroup(),
],
'group_user' => [
['user_id' => 1, 'group_id' => 1],
['user_id' => 2, 'group_id' => 3],
],
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 3],
['permission' => 'startDiscussion', 'group_id' => 3],
]
]);
}
@@ -137,4 +144,76 @@ class CreateTest extends TestCase
$this->assertEquals('test - too-obscure', $discussion->title);
$this->assertEquals('test - too-obscure', Arr::get($data, 'data.attributes.title'));
}
/**
* @test
*/
public function discussion_creation_limited_by_throttler()
{
$this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'predetermined content for automated testing - too-obscure',
],
]
],
])
);
$response = $this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'Second predetermined content for automated testing - too-obscure',
],
]
],
])
);
$this->assertEquals(429, $response->getStatusCode());
}
/**
* @test
*/
public function throttler_doesnt_apply_to_admin()
{
$this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'predetermined content for automated testing - too-obscure',
],
]
],
])
);
$response = $this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'title' => 'test - too-obscure',
'content' => 'Second predetermined content for automated testing - too-obscure',
],
]
],
])
);
$this->assertEquals(201, $response->getStatusCode());
}
}

View File

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

View File

@@ -64,4 +64,44 @@ class CreateTest extends TestCase
$this->assertEquals(201, $response->getStatusCode());
}
/**
* @test
*/
public function limited_by_throttler()
{
$this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'content' => 'reply with predetermined content for automated testing - too-obscure',
],
'relationships' => [
'discussion' => ['data' => ['id' => 1]],
],
],
],
])
);
$response = $this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'attributes' => [
'content' => 'Second reply with predetermined content for automated testing - too-obscure',
],
'relationships' => [
'discussion' => ['data' => ['id' => 1]],
],
],
],
])
);
$this->assertEquals(429, $response->getStatusCode());
}
}

View File

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

View File

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

View File

@@ -0,0 +1,523 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Post\Post;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
class ApiSerializerTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb()
{
$this->prepareDatabase([
'users' => [
$this->adminUser(),
$this->normalUser()
],
'discussions' => [
['id' => 1, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
['id' => 2, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
['id' => 3, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'discussionRenamed', 'content' => '<t><p>can i haz relationz?</p></t>'],
],
]);
}
protected function prepSettingsDb()
{
$this->prepareDatabase([
'settings' => [
['key' => 'customPrefix.customSetting', 'value' => 'customValue']
],
]);
}
/**
* @test
*/
public function custom_attributes_dont_exist_by_default()
{
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayNotHasKey('customAttribute', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_exist_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->mutate(function () {
return [
'customAttribute' => true
];
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttribute', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_with_invokable_exist_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->mutate(CustomAttributesInvokableClass::class)
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttributeFromInvokable', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_exist_if_added_to_parent_class()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->mutate(function () {
return [
'customAttribute' => true
];
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttribute', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_attributes_prioritize_child_classes()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->mutate(function () {
return [
'customAttribute' => 'initialValue'
];
}),
(new Extend\ApiSerializer(UserSerializer::class))
->mutate(function () {
return [
'customAttribute' => 'newValue'
];
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customAttribute', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['customAttribute']);
}
/**
* @test
*/
public function custom_single_attribute_exists_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('customSingleAttribute', function () {
return true;
})->attribute('customSingleAttribute_0', function () {
return 0;
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute', $payload['data']['attributes']);
$this->assertArrayHasKey('customSingleAttribute_0', $payload['data']['attributes']);
$this->assertEquals(0, $payload['data']['attributes']['customSingleAttribute_0']);
}
/**
* @test
*/
public function custom_single_attribute_with_invokable_exists_if_added()
{
$this->extend(
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('customSingleAttribute_1', CustomSingleAttributeInvokableClass::class)
);
$this->app();
$response = $this->send(
$this->request('GET', '/api', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute_1', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_single_attribute_exists_if_added_to_parent_class()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->attribute('customSingleAttribute_2', function () {
return true;
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute_2', $payload['data']['attributes']);
}
/**
* @test
*/
public function custom_single_attribute_prioritizes_child_classes()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->attribute('customSingleAttribute_3', function () {
return 'initialValue';
}),
(new Extend\ApiSerializer(UserSerializer::class))
->attribute('customSingleAttribute_3', function () {
return 'newValue';
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('customSingleAttribute_3', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['customSingleAttribute_3']);
}
/**
* @test
*/
public function custom_attributes_can_be_overriden()
{
$this->extend(
(new Extend\ApiSerializer(BasicUserSerializer::class))
->attribute('someCustomAttribute', function () {
return 'newValue';
})->mutate(function () {
return [
'someCustomAttribute' => 'initialValue',
'someOtherCustomAttribute' => 'initialValue',
];
})->attribute('someOtherCustomAttribute', function () {
return 'newValue';
})
);
$this->app();
$response = $this->send(
$this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
])
);
$payload = json_decode($response->getBody(), true);
$this->assertArrayHasKey('someCustomAttribute', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['someCustomAttribute']);
$this->assertArrayHasKey('someOtherCustomAttribute', $payload['data']['attributes']);
$this->assertEquals('newValue', $payload['data']['attributes']['someOtherCustomAttribute']);
}
/**
* @test
*/
public function custom_hasMany_relationship_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasMany('customSerializerRelation', DiscussionSerializer::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertCount(3, $relationship->toArray()['data']);
}
/**
* @test
*/
public function custom_hasOne_relationship_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->hasOne('customSerializerRelation', DiscussionSerializer::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
/**
* @test
*/
public function custom_relationship_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->relationship('customSerializerRelation', function (AbstractSerializer $serializer, $model) {
return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation');
})
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
/**
* @test
*/
public function custom_relationship_with_invokable_exists_if_added()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('customSerializerRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(UserSerializer::class))
->relationship('customSerializerRelation', CustomRelationshipInvokableClass::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
/**
* @test
*/
public function custom_relationship_is_inherited_to_child_classes()
{
$this->extend(
(new Extend\Model(User::class))
->hasMany('anotherCustomRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(BasicUserSerializer::class))
->hasMany('anotherCustomRelation', DiscussionSerializer::class)
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'anotherCustomRelation');
$this->assertNotEmpty($relationship);
$this->assertCount(3, $relationship->toArray()['data']);
}
/**
* @test
*/
public function custom_relationship_prioritizes_child_classes()
{
$this->extend(
(new Extend\Model(User::class))
->hasOne('postCustomRelation', Post::class, 'user_id'),
(new Extend\Model(User::class))
->hasOne('discussionCustomRelation', Discussion::class, 'user_id'),
(new Extend\ApiSerializer(BasicUserSerializer::class))
->hasOne('postCustomRelation', PostSerializer::class),
(new Extend\ApiSerializer(UserSerializer::class))
->relationship('postCustomRelation', function (AbstractSerializer $serializer, $model) {
return $serializer->hasOne($model, DiscussionSerializer::class, 'discussionCustomRelation');
})
);
$this->prepDb();
$request = $this->request('GET', '/api/users/2', [
'authenticatedAs' => 1,
]);
$serializer = $this->app()->getContainer()->make(UserSerializer::class);
$serializer->setRequest($request);
$relationship = $serializer->getRelationship(User::find(2), 'postCustomRelation');
$this->assertNotEmpty($relationship);
$this->assertEquals('discussions', $relationship->toArray()['data']['type']);
}
}
class CustomAttributesInvokableClass
{
public function __invoke()
{
return [
'customAttributeFromInvokable' => true
];
}
}
class CustomSingleAttributeInvokableClass
{
public function __invoke()
{
return true;
}
}
class CustomRelationshipInvokableClass
{
public function __invoke(AbstractSerializer $serializer, $model)
{
return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation');
}
}

View File

@@ -29,6 +29,12 @@ class MailTest extends TestCase
'users' => [
$this->adminUser(),
],
'groups' => [
$this->adminGroup(),
],
'group_user' => [
['user_id' => 1, 'group_id' => 1],
],
]);
}

View File

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

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Flarum\Extend;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
class ThrottleApiTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function prepDb(): void
{
$this->prepareDatabase([
'users' => [
$this->normalUser(),
],
'groups' => [
$this->memberGroup(),
],
'group_user' => [
['user_id' => 2, 'group_id' => 3],
],
'group_permission' => [
['permission' => 'viewDiscussions', 'group_id' => 3],
]
]);
}
/**
* @test
*/
public function list_discussions_not_restricted_by_default()
{
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @test
*/
public function list_discussions_can_be_restricted()
{
$this->extend((new Extend\ThrottleApi)->set('blockListDiscussions', function ($request) {
if ($request->getAttribute('routeName') === 'discussions.index') {
return true;
}
}));
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(429, $response->getStatusCode());
}
/**
* @test
*/
public function false_overrides_true_for_evaluating_throttlers()
{
$this->extend(
(new Extend\ThrottleApi)->set('blockListDiscussions', function ($request) {
if ($request->getAttribute('routeName') === 'discussions.index') {
return true;
}
}),
(new Extend\ThrottleApi)->set('blockListDiscussionsOverride', function ($request) {
if ($request->getAttribute('routeName') === 'discussions.index') {
return false;
}
})
);
$this->prepDb();
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
$this->assertEquals(200, $response->getStatusCode());
}
}

View File

@@ -26,6 +26,9 @@ class UserTest extends TestCase
$this->adminUser(),
$this->normalUser(),
],
'group_permission' => [
['permission' => 'viewUserList', 'group_id' => 3]
],
'settings' => [
['key' => 'display_name_driver', 'value' => 'custom'],
],

View File

@@ -6,7 +6,7 @@
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
processIsolation="true"
stopOnFailure="false">
<testsuites>

View File

@@ -7,7 +7,7 @@
@foreach ($apiDocument->data as $discussion)
<li>
<a href="{{ $url->to('forum')->route('discussion', [
'id' => $discussion->attributes->slug
'id' => $discussion->id . (trim($discussion->attributes->slug) ? '-' . $discussion->attributes->slug : '')
]) }}">
{{ $discussion->attributes->title }}
</a>