refactor: JSON:API (#3971)

* refactor: json:api refactor iteration 1
* chore: delete dead code
* fix: regressions
* chore: move additions/changes to package
* feat: AccessTokenResource
* feat: allow dependency injection in resources
* feat: `ApiResource` extender
* feat: improve
* feat: refactor tags extension
* feat: refactor flags extension
* fix: regressions
* fix: drop bc layer
* feat: refactor suspend extension
* feat: refactor subscriptions extension
* feat: refactor approval extension
* feat: refactor sticky extension
* feat: refactor nicknames extension
* feat: refactor mentions extension
* feat: refactor lock extension
* feat: refactor likes extension
* chore: merge conflicts
* feat: refactor extension-manager extension
* feat: context current endpoint helpers
* chore: minor
* feat: cleaner sortmap implementation
* chore: drop old package
* chore: not needed (auto scoping)
* fix: actor only fields
* refactor: simplify index endpoint
* feat: eager loading
* test: adapt
* test: phpstan
* test: adapt
* fix: typing
* fix: approving content
* tet: adapt frontend tests
* chore: typings
* chore: review
* fix: breaking change
This commit is contained in:
Sami Mazouz 2024-06-21 09:36:32 +01:00 committed by GitHub
parent 10514709f1
commit a8777c6198
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
296 changed files with 7148 additions and 8860 deletions

View File

@ -108,7 +108,7 @@
"php": "^8.1",
"ext-json": "*",
"components/font-awesome": "^5.15.0",
"composer/composer": "^2.0",
"composer/composer": "^2.7",
"dflydev/fig-cookies": "^3.0",
"doctrine/dbal": "^3.6.2",
"dragonmantank/cron-expression": "^3.3",
@ -151,7 +151,6 @@
"pusher/pusher-php-server": "^7.2",
"s9e/text-formatter": "^2.13",
"staudenmeir/eloquent-eager-limit": "^1.8.2",
"sycho/json-api": "^0.5.0",
"sycho/sourcemap": "^2.0.0",
"symfony/config": "^6.3",
"symfony/console": "^6.3",
@ -163,6 +162,7 @@
"symfony/postmark-mailer": "^6.3",
"symfony/translation": "^6.3",
"symfony/yaml": "^6.3",
"flarum/json-api-server": "^1.0.0",
"wikimedia/less.php": "^4.1"
},
"require-dev": {

View File

@ -7,9 +7,10 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Approval\Access;
use Flarum\Approval\Api\PostResourceFields;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Approval\Listener;
use Flarum\Discussion\Discussion;
@ -36,17 +37,13 @@ return [
->default('is_approved', true)
->cast('is_approved', 'bool'),
(new Extend\ApiSerializer(BasicDiscussionSerializer::class))
->attribute('isApproved', function (BasicDiscussionSerializer $serializer, Discussion $discussion): bool {
return $discussion->is_approved;
}),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(fn () => [
Schema\Boolean::make('isApproved'),
]),
(new Extend\ApiSerializer(PostSerializer::class))
->attribute('isApproved', function ($serializer, Post $post) {
return (bool) $post->is_approved;
})->attribute('canApprove', function (PostSerializer $serializer, Post $post) {
return (bool) $serializer->getActor()->can('approvePosts', $post->discussion);
}),
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class),
new Extend\Locales(__DIR__.'/locale'),

View File

@ -0,0 +1,29 @@
<?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\Approval\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Post\Post;
class PostResourceFields
{
public function __invoke(): array
{
return [
Schema\Boolean::make('isApproved')
->writable(fn (Post $post, Context $context) => $context->getActor()->can('approve', $post))
// set by the ApproveContent listener.
->set(fn () => null),
Schema\Boolean::make('canApprove')
->get(fn (Post $post, Context $context) => $context->getActor()->can('approvePosts', $post->discussion)),
];
}
}

View File

@ -0,0 +1,123 @@
<?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\Approval\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class ApprovePostsTest extends TestCase
{
use RetrievesAuthorizedUsers;
use InteractsWithUnapprovedContent;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-approval');
$this->prepareDatabase([
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 3],
['id' => 4, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => Carbon::now(), 'is_approved' => 1, 'number' => 4],
['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 5],
],
'groups' => [
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
],
'group_user' => [
['user_id' => 3, 'group_id' => 4],
],
'group_permission' => [
['group_id' => 4, 'permission' => 'discussion.approvePosts'],
]
]);
}
/**
* @test
*/
public function can_approve_unapproved_post()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/3', [
'authenticatedAs' => 3,
'json' => [
'data' => [
'attributes' => [
'isApproved' => true
]
]
]
])
);
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals(1, $this->database()->table('posts')->where('id', 3)->where('is_approved', 1)->count());
}
/**
* @test
*/
public function cannot_approve_post_without_permission()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/3', [
'authenticatedAs' => 4,
'json' => [
'data' => [
'attributes' => [
'isApproved' => true
]
]
]
])
);
$this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals(0, $this->database()->table('posts')->where('id', 3)->where('is_approved', 1)->count());
}
/**
* @test
*/
public function hiding_post_silently_approves_it()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/5', [
'authenticatedAs' => 3,
'json' => [
'data' => [
'attributes' => [
'isHidden' => true
]
]
]
])
);
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals(1, $this->database()->table('posts')->where('id', 5)->where('is_approved', 1)->count());
}
}

View File

@ -0,0 +1,153 @@
<?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\Approval\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent;
use Flarum\Group\Group;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CreatePostsTest extends TestCase
{
use RetrievesAuthorizedUsers;
use InteractsWithUnapprovedContent;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-flags', 'flarum-approval');
$this->prepareDatabase([
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 2, 'comment_count' => 1, 'is_approved' => 0],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 3],
['id' => 4, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 5, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 6, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 3],
['id' => 7, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 8, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 9, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 0, 'number' => 3],
],
'groups' => [
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
],
'group_user' => [
['user_id' => 3, 'group_id' => 4],
['user_id' => 2, 'group_id' => 5],
],
'group_permission' => [
['group_id' => 4, 'permission' => 'discussion.startWithoutApproval'],
['group_id' => 5, 'permission' => 'discussion.replyWithoutApproval'],
]
]);
}
/**
* @dataProvider startDiscussionDataProvider
* @test
*/
public function can_start_discussion_without_approval_when_allowed(int $authenticatedAs, bool $allowed)
{
$this->database()->table('group_permission')->where('group_id', Group::MEMBER_ID)->where('permission', 'discussion.startWithoutApproval')->delete();
$response = $this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => $authenticatedAs,
'json' => [
'data' => [
'type' => 'discussions',
'attributes' => [
'title' => 'This is a new discussion',
'content' => 'This is a new discussion',
]
]
]
])
);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$this->assertEquals($allowed ? 1 : 0, $this->database()->table('discussions')->where('id', $json['data']['id'])->value('is_approved'));
}
/**
* @dataProvider replyToDiscussionDataProvider
* @test
*/
public function can_reply_without_approval_when_allowed(?int $authenticatedAs, bool $allowed)
{
$this->database()->table('group_permission')->where('group_id', Group::MEMBER_ID)->where('permission', 'discussion.replyWithoutApproval')->delete();
$response = $this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => $authenticatedAs,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'This is a new reply',
],
'relationships' => [
'discussion' => [
'data' => [
'type' => 'discussions',
'id' => 1
]
]
]
]
]
])
);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$this->assertEquals($allowed ? 1 : 0, $this->database()->table('posts')->where('id', $json['data']['id'])->value('is_approved'));
}
public static function startDiscussionDataProvider(): array
{
return [
'Admin' => [1, true],
'User without permission' => [2, false],
'Permission Given' => [3, true],
'Another user without permission' => [4, false],
];
}
public static function replyToDiscussionDataProvider(): array
{
return [
'Admin' => [1, true],
'User without permission' => [3, false],
'Permission Given' => [2, true],
'Another user without permission' => [4, false],
];
}
}

View File

@ -7,25 +7,17 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Api\Controller\ListPostsController;
use Flarum\Api\Controller\ShowDiscussionController;
use Flarum\Api\Controller\ShowPostController;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Extend;
use Flarum\Flags\Access\ScopeFlagVisibility;
use Flarum\Flags\AddCanFlagAttribute;
use Flarum\Flags\AddFlagsApiAttributes;
use Flarum\Flags\AddNewFlagCountAttribute;
use Flarum\Flags\Api\Controller\CreateFlagController;
use Flarum\Flags\Api\Controller\DeleteFlagsController;
use Flarum\Flags\Api\Controller\ListFlagsController;
use Flarum\Flags\Api\Serializer\FlagSerializer;
use Flarum\Flags\Api\ForumResourceFields;
use Flarum\Flags\Api\PostResourceFields;
use Flarum\Flags\Api\Resource\FlagResource;
use Flarum\Flags\Api\UserResourceFields;
use Flarum\Flags\Flag;
use Flarum\Flags\Listener;
use Flarum\Flags\PrepareFlagsApiData;
use Flarum\Forum\Content\AssertRegistered;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Post;
@ -41,8 +33,6 @@ return [
->js(__DIR__.'/js/dist/admin.js'),
(new Extend\Routes('api'))
->get('/flags', 'flags.index', ListFlagsController::class)
->post('/flags', 'flags.create', CreateFlagController::class)
->delete('/posts/{id}/flags', 'flags.delete', DeleteFlagsController::class),
(new Extend\Model(User::class))
@ -51,27 +41,26 @@ return [
(new Extend\Model(Post::class))
->hasMany('flags', Flag::class, 'post_id'),
(new Extend\ApiSerializer(PostSerializer::class))
->hasMany('flags', FlagSerializer::class)
->attribute('canFlag', AddCanFlagAttribute::class),
new Extend\ApiResource(FlagResource::class),
(new Extend\ApiSerializer(CurrentUserSerializer::class))
->attribute('newFlagCount', AddNewFlagCountAttribute::class),
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class),
(new Extend\ApiSerializer(ForumSerializer::class))
->attributes(AddFlagsApiAttributes::class),
(new Extend\ApiResource(Resource\UserResource::class))
->fields(UserResourceFields::class),
(new Extend\ApiController(ShowDiscussionController::class))
->addInclude(['posts.flags', 'posts.flags.user']),
(new Extend\ApiResource(Resource\ForumResource::class))
->fields(ForumResourceFields::class),
(new Extend\ApiController(ListPostsController::class))
->addInclude(['flags', 'flags.user']),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) {
return $endpoint->addDefaultInclude(['posts.flags', 'posts.flags.user']);
}),
(new Extend\ApiController(ShowPostController::class))
->addInclude(['flags', 'flags.user']),
(new Extend\ApiController(AbstractSerializeController::class))
->prepareDataForSerialization(PrepareFlagsApiData::class),
(new Extend\ApiResource(Resource\PostResource::class))
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint) {
return $endpoint->addDefaultInclude(['flags', 'flags.user']);
}),
(new Extend\Settings())
->serializeToForum('guidelinesUrl', 'flarum-flags.guidelines_url'),

2
extensions/flags/js/dist/forum.js generated 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

@ -151,7 +151,6 @@ export default class FlagPostModal extends FormModal {
reason: this.reason() === 'other' ? null : this.reason(),
reasonDetail: this.reasonDetail(),
relationships: {
user: app.session.user,
post: this.attrs.post,
},
},

View File

@ -10,7 +10,6 @@
namespace Flarum\Flags\Access;
use Flarum\Extension\ExtensionManager;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
@ -23,31 +22,26 @@ class ScopeFlagVisibility
public function __invoke(User $actor, Builder $query): void
{
$query
->whereHas('post', function (Builder $query) use ($actor) {
$query->whereVisibleTo($actor);
})
->where(function (Builder $query) use ($actor) {
if ($this->extensions->isEnabled('flarum-tags')) {
$query
->select('flags.*')
->leftJoin('posts', 'posts.id', '=', 'flags.post_id')
->leftJoin('discussions', 'discussions.id', '=', 'posts.discussion_id')
->whereNotExists(function ($query) use ($actor) {
return $query->selectRaw('1')
->from('discussion_tag')
->whereNotIn('tag_id', function ($query) use ($actor) {
Tag::query()->setQuery($query->from('tags'))->whereHasPermission($actor, 'discussion.viewFlags')->select('tags.id');
})
->whereColumn('discussions.id', 'discussion_id');
->whereHas('post.discussion.tags', function ($query) use ($actor) {
$query->whereHasPermission($actor, 'discussion.viewFlags');
});
if (! $actor->hasPermission('discussion.viewFlags')) {
$query->whereExists(function ($query) {
return $query->selectRaw('1')
->from('discussion_tag')
->whereColumn('discussions.id', 'discussion_id');
});
if ($actor->hasPermission('discussion.viewFlags')) {
$query->orWhereDoesntHave('post.discussion.tags');
}
}
if (! $actor->hasPermission('discussion.viewFlags')) {
$query->orWhere('flags.user_id', $actor->id);
}
});
}
}

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\Flags;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
class AddCanFlagAttribute
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function __invoke(PostSerializer $serializer, Post $post): bool
{
return $serializer->getActor()->can('flag', $post) && $this->checkFlagOwnPostSetting($serializer->getActor(), $post);
}
protected function checkFlagOwnPostSetting(User $actor, Post $post): bool
{
if ($actor->id === $post->user_id) {
// If $actor is the post author, check to see if the setting is enabled
return (bool) $this->settings->get('flarum-flags.can_flag_own');
}
// $actor is not the post author
return true;
}
}

View File

@ -1,40 +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\Flags;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
class AddFlagsApiAttributes
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function __invoke(ForumSerializer $serializer): array
{
$attributes = [
'canViewFlags' => $serializer->getActor()->hasPermissionLike('discussion.viewFlags')
];
if ($attributes['canViewFlags']) {
$attributes['flagCount'] = (int) $this->getFlagCount($serializer->getActor());
}
return $attributes;
}
protected function getFlagCount(User $actor): int
{
return Flag::whereVisibleTo($actor)->distinct()->count('flags.post_id');
}
}

View File

@ -1,32 +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\Flags;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\User\User;
class AddNewFlagCountAttribute
{
public function __invoke(CurrentUserSerializer $serializer, User $user): int
{
return $this->getNewFlagCount($user);
}
protected function getNewFlagCount(User $actor): int
{
$query = Flag::whereVisibleTo($actor);
if ($time = $actor->read_flags_at) {
$query->where('flags.created_at', '>', $time);
}
return $query->distinct()->count('flags.post_id');
}
}

View File

@ -1,43 +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\Flags\Api\Controller;
use Flarum\Api\Controller\AbstractCreateController;
use Flarum\Flags\Api\Serializer\FlagSerializer;
use Flarum\Flags\Command\CreateFlag;
use Flarum\Flags\Flag;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateFlagController extends AbstractCreateController
{
public ?string $serializer = FlagSerializer::class;
public array $include = [
'post',
'post.flags',
'user'
];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Flag
{
return $this->bus->dispatch(
new CreateFlag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
);
}
}

View File

@ -1,81 +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\Flags\Api\Controller;
use Carbon\Carbon;
use Flarum\Api\Controller\AbstractListController;
use Flarum\Flags\Api\Serializer\FlagSerializer;
use Flarum\Flags\Flag;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListFlagsController extends AbstractListController
{
public ?string $serializer = FlagSerializer::class;
public array $include = [
'user',
'post',
'post.user',
'post.discussion'
];
public function __construct(
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$actor->assertRegistered();
$actor->read_flags_at = Carbon::now();
$actor->save();
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = $this->extractInclude($request);
if (in_array('post.user', $include)) {
$include[] = 'post.user.groups';
}
$flags = Flag::whereVisibleTo($actor)
->latest('flags.created_at')
->groupBy('post_id')
->limit($limit + 1)
->offset($offset)
->get();
$this->loadRelations($flags, $include, $request);
$flags = $flags->all();
$areMoreResults = false;
if (count($flags) > $limit) {
array_pop($flags);
$areMoreResults = true;
}
$this->addPaginationData(
$document,
$request,
$this->url->to('api')->route('flags.index'),
$areMoreResults ? null : 0
);
return $flags;
}
}

View File

@ -0,0 +1,32 @@
<?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\Flags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Flags\Flag;
class ForumResourceFields
{
public function __invoke(): array
{
return [
Schema\Boolean::make('canViewFlags')
->get(function (object $model, Context $context) {
return $context->getActor()->hasPermissionLike('discussion.viewFlags');
}),
Schema\Integer::make('flagCount')
->visible(fn (object $model, Context $context) => $context->getActor()->hasPermissionLike('discussion.viewFlags'))
->get(function (object $model, Context $context) {
return Flag::whereVisibleTo($context->getActor())->distinct()->count('flags.post_id');
}),
];
}
}

View File

@ -0,0 +1,42 @@
<?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\Flags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
class PostResourceFields
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function __invoke(): array
{
return [
Schema\Boolean::make('canFlag')
->get(function (Post $post, Context $context) {
$actor = $context->getActor();
return $actor->can('flag', $post) && (
// $actor is not the post author
$actor->id !== $post->user_id
// If $actor is the post author, check to see if the setting is enabled
|| ((bool) $this->settings->get('flarum-flags.can_flag_own'))
);
}),
Schema\Relationship\ToMany::make('flags')
->includable(),
];
}
}

View File

@ -0,0 +1,165 @@
<?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\Flags\Api\Resource;
use Carbon\Carbon;
use Flarum\Api\Context as FlarumContext;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Flags\Event\Created;
use Flarum\Flags\Flag;
use Flarum\Http\Exception\InvalidParameterException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Tobyz\JsonApiServer\Context;
/**
* @extends AbstractDatabaseResource<Flag>
*/
class FlagResource extends AbstractDatabaseResource
{
public function __construct(
protected PostRepository $posts,
protected TranslatorInterface $translator,
protected SettingsRepositoryInterface $settings,
) {
}
public function type(): string
{
return 'flags';
}
public function model(): string
{
return Flag::class;
}
public function query(Context $context): object
{
if ($context->listing(self::class)) {
$query = Flag::query()->groupBy('post_id');
$this->scope($query, $context);
return $query;
}
return parent::query($context);
}
public function scope(Builder $query, Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function newModel(Context $context): object
{
if ($context->creating(self::class)) {
Flag::unguard();
return Flag::query()->firstOrNew([
'post_id' => (int) Arr::get($context->body(), 'data.relationships.post.data.id'),
'user_id' => $context->getActor()->id
], [
'type' => 'user',
]);
}
return parent::newModel($context);
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->authenticated()
->defaultInclude(['post', 'post.flags', 'user']),
Endpoint\Index::make()
->authenticated()
->defaultInclude(['user', 'post', 'post.user', 'post.discussion'])
->defaultSort('-createdAt')
->paginate()
->after(function (FlarumContext $context, $data) {
$actor = $context->getActor();
$actor->read_flags_at = Carbon::now();
$actor->save();
return $data;
}),
];
}
public function fields(): array
{
return [
Schema\Str::make('type'),
Schema\Str::make('reason')
->writableOnCreate()
->nullable()
->requiredOnCreateWithout(['reasonDetail'])
->validationMessages([
'reason.required_without' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message'),
]),
Schema\Str::make('reasonDetail')
->writableOnCreate()
->nullable()
->requiredOnCreateWithout(['reason'])
->validationMessages([
'reasonDetail.required_without' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message'),
]),
Schema\DateTime::make('createdAt'),
Schema\Relationship\ToOne::make('post')
->includable()
->writable(fn (Flag $flag, FlarumContext $context) => $context->creating())
->set(function (Flag $flag, Post $post, FlarumContext $context) {
if (! ($post instanceof CommentPost)) {
throw new InvalidParameterException;
}
$actor = $context->getActor();
$actor->assertCan('flag', $post);
if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) {
throw new PermissionDeniedException;
}
$flag->post_id = $post->id;
}),
Schema\Relationship\ToOne::make('user')
->includable(),
];
}
public function sorts(): array
{
return [
SortColumn::make('createdAt'),
];
}
public function created(object $model, Context $context): ?object
{
$this->events->dispatch(new Created($model, $context->getActor(), $context->body()));
return parent::created($model, $context);
}
}

View File

@ -1,48 +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\Flags\Api\Serializer;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Flags\Flag;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class FlagSerializer extends AbstractSerializer
{
protected $type = 'flags';
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Flag)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.Flag::class
);
}
return [
'type' => $model->type,
'reason' => $model->reason,
'reasonDetail' => $model->reason_detail,
'createdAt' => $this->formatDate($model->created_at),
];
}
protected function post(Flag $flag): ?Relationship
{
return $this->hasOne($flag, PostSerializer::class);
}
protected function user(Flag $flag): ?Relationship
{
return $this->hasOne($flag, BasicUserSerializer::class);
}
}

View File

@ -0,0 +1,36 @@
<?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\Flags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Flags\Flag;
use Flarum\User\User;
class UserResourceFields
{
public function __invoke(): array
{
return [
Schema\Integer::make('newFlagCount')
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id)
->get(function (User $user, Context $context) {
$actor = $context->getActor();
$query = Flag::whereVisibleTo($actor);
if ($time = $actor->read_flags_at) {
$query->where('flags.created_at', '>', $time);
}
return $query->distinct()->count('flags.post_id');
}),
];
}
}

View File

@ -1,79 +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\Flags\Command;
use Carbon\Carbon;
use Flarum\Flags\Event\Created;
use Flarum\Flags\Flag;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Post\CommentPost;
use Flarum\Post\PostRepository;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Arr;
use Tobscure\JsonApi\Exception\InvalidParameterException;
class CreateFlagHandler
{
public function __construct(
protected PostRepository $posts,
protected TranslatorInterface $translator,
protected SettingsRepositoryInterface $settings,
protected Dispatcher $events
) {
}
public function handle(CreateFlag $command): Flag
{
$actor = $command->actor;
$data = $command->data;
$postId = Arr::get($data, 'relationships.post.data.id');
$post = $this->posts->findOrFail($postId, $actor);
if (! ($post instanceof CommentPost)) {
throw new InvalidParameterException;
}
$actor->assertCan('flag', $post);
if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) {
throw new PermissionDeniedException();
}
if (Arr::get($data, 'attributes.reason') === null && Arr::get($data, 'attributes.reasonDetail') === '') {
throw new ValidationException([
'message' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message')
]);
}
Flag::unguard();
$flag = Flag::firstOrNew([
'post_id' => $post->id,
'user_id' => $actor->id
]);
$flag->post_id = $post->id;
$flag->user_id = $actor->id;
$flag->type = 'user';
$flag->reason = Arr::get($data, 'attributes.reason');
$flag->reason_detail = Arr::get($data, 'attributes.reasonDetail');
$flag->created_at = Carbon::now();
$flag->save();
$this->events->dispatch(new Created($flag, $actor, $data));
return $flag;
}
}

View File

@ -33,6 +33,10 @@ class Flag extends AbstractModel
use ScopeVisibilityTrait;
use HasFactory;
public $timestamps = true;
public const UPDATED_AT = null;
protected $casts = ['created_at' => 'datetime'];
public function post(): BelongsTo

View File

@ -1,64 +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\Flags;
use Flarum\Api\Controller;
use Flarum\Flags\Api\Controller\CreateFlagController;
use Flarum\Http\RequestUtil;
use Illuminate\Database\Eloquent\Collection;
use Psr\Http\Message\ServerRequestInterface;
class PrepareFlagsApiData
{
public function __invoke(Controller\AbstractSerializeController $controller, mixed $data, ServerRequestInterface $request): void
{
// For any API action that allows the 'flags' relationship to be
// included, we need to preload this relationship onto the data (Post
// models) so that we can selectively expose only the flags that the
// user has permission to view.
if ($controller instanceof Controller\ShowDiscussionController) {
if ($data->relationLoaded('posts')) {
$posts = $data->getRelation('posts');
}
}
if ($controller instanceof Controller\ListPostsController) {
$posts = $data->all();
}
if ($controller instanceof Controller\ShowPostController) {
$posts = [$data];
}
if ($controller instanceof CreateFlagController) {
$posts = [$data->post];
}
if (isset($posts)) {
$actor = RequestUtil::getActor($request);
$postsWithPermission = [];
foreach ($posts as $post) {
if (is_object($post)) {
$post->setRelation('flags', null);
if ($actor->can('viewFlags', $post->discussion)) {
$postsWithPermission[] = $post;
}
}
}
if (count($postsWithPermission)) {
(new Collection($postsWithPermission))
->load('flags', 'flags.user');
}
}
}
}

View File

@ -55,6 +55,7 @@ class ListTest extends TestCase
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 4, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>', 'is_private' => true],
],
Flag::class => [
['id' => 1, 'post_id' => 1, 'user_id' => 1],
@ -62,6 +63,7 @@ class ListTest extends TestCase
['id' => 3, 'post_id' => 1, 'user_id' => 3],
['id' => 4, 'post_id' => 2, 'user_id' => 2],
['id' => 5, 'post_id' => 3, 'user_id' => 1],
['id' => 6, 'post_id' => 4, 'user_id' => 1],
]
]);
}
@ -69,7 +71,7 @@ class ListTest extends TestCase
/**
* @test
*/
public function admin_can_see_one_flag_per_post()
public function admin_can_see_one_flag_per_visible_post()
{
$response = $this->send(
$this->request('GET', '/api/flags', [
@ -77,9 +79,9 @@ class ListTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$data = json_decode($response->getBody()->getContents(), true)['data'];
$data = json_decode($body, true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['1', '4', '5'], $ids);
@ -88,7 +90,7 @@ class ListTest extends TestCase
/**
* @test
*/
public function regular_user_sees_own_flags()
public function regular_user_sees_own_flags_of_visible_posts()
{
$response = $this->send(
$this->request('GET', '/api/flags', [
@ -107,7 +109,7 @@ class ListTest extends TestCase
/**
* @test
*/
public function mod_can_see_one_flag_per_post()
public function mod_can_see_one_flag_per_visible_post()
{
$response = $this->send(
$this->request('GET', '/api/flags', [

View File

@ -55,9 +55,9 @@ class ListWithTagsTest extends TestCase
],
'group_permission' => [
['group_id' => Group::MODERATOR_ID, 'permission' => 'discussion.viewFlags'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag2.viewDiscussions'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag2.viewForum'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag3.discussion.viewFlags'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.viewDiscussions'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.viewForum'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.discussion.viewFlags'],
],
Discussion::class => [
@ -154,9 +154,7 @@ class ListWithTagsTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id');
// 7 is included, even though mods can't view discussions.
// This is because the UI doesnt allow discussions.viewFlags without viewDiscussions.
$this->assertEqualsCanonicalizing(['1', '4', '5', '7', '8', '9'], $ids);
$this->assertEqualsCanonicalizing(['1', '4', '5', '8', '9'], $ids);
}
/**

View File

@ -0,0 +1,145 @@
<?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\Flags\Tests\integration\api\posts;
use Flarum\Group\Group;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Illuminate\Support\Arr;
class IncludeFlagsVisibilityTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setup(): void
{
parent::setUp();
$this->extension('flarum-tags', 'flarum-flags');
$this->prepareDatabase([
'users' => [
$this->normalUser(),
[
'id' => 3,
'username' => 'mod',
'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
'email' => 'normal2@machine.local',
'is_email_confirmed' => 1,
],
[
'id' => 4,
'username' => 'tod',
'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
'email' => 'tod@machine.local',
'is_email_confirmed' => 1,
],
[
'id' => 5,
'username' => 'ted',
'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
'email' => 'ted@machine.local',
'is_email_confirmed' => 1,
],
],
'group_user' => [
['group_id' => 5, 'user_id' => 2],
['group_id' => 6, 'user_id' => 3],
],
'groups' => [
['id' => 5, 'name_singular' => 'group5', 'name_plural' => 'group5', 'color' => null, 'icon' => 'fas fa-crown', 'is_hidden' => false],
['id' => 6, 'name_singular' => 'group1', 'name_plural' => 'group1', 'color' => null, 'icon' => 'fas fa-cog', 'is_hidden' => false],
],
'group_permission' => [
['group_id' => Group::MEMBER_ID, 'permission' => 'tag1.viewForum'],
['group_id' => 5, 'permission' => 'tag1.viewForum'],
['group_id' => 5, 'permission' => 'discussion.viewFlags'],
['group_id' => 6, 'permission' => 'tag1.discussion.viewFlags'],
['group_id' => 6, 'permission' => 'tag1.viewForum'],
],
'tags' => [
['id' => 1, 'name' => 'Tag 1', 'slug' => 'tag-1', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true],
['id' => 2, 'name' => 'Tag 2', 'slug' => 'tag-2', 'is_primary' => true, 'position' => 2, 'parent_id' => null, 'is_restricted' => false],
],
'discussions' => [
['id' => 1, 'title' => 'Test1', 'user_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => 'Test2', 'user_id' => 1, 'comment_count' => 1],
],
'discussion_tag' => [
['discussion_id' => 1, 'tag_id' => 1],
['discussion_id' => 2, 'tag_id' => 2],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 4, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 5, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
],
'flags' => [
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 5],
['id' => 3, 'post_id' => 1, 'user_id' => 3],
['id' => 4, 'post_id' => 2, 'user_id' => 5],
['id' => 5, 'post_id' => 3, 'user_id' => 1],
['id' => 6, 'post_id' => 4, 'user_id' => 1],
['id' => 7, 'post_id' => 5, 'user_id' => 5],
['id' => 8, 'post_id' => 5, 'user_id' => 5],
],
]);
}
/**
* @dataProvider listFlagsIncludesDataProvider
* @test
*/
public function user_sees_where_allowed_with_included_tags(int $actorId, array $expectedIncludes)
{
$response = $this->send(
$this->request('GET', '/api/posts', [
'authenticatedAs' => $actorId,
])->withQueryParams([
'include' => 'flags'
])
);
$this->assertEquals(200, $response->getStatusCode());
$responseBody = json_decode($response->getBody()->getContents(), true);
$data = $responseBody['data'];
$this->assertEquals(['1', '2', '3', '4', '5'], Arr::pluck($data, 'id'));
$this->assertEqualsCanonicalizing(
$expectedIncludes,
collect($responseBody['included'] ?? [])
->filter(fn ($include) => $include['type'] === 'flags')
->pluck('id')
->map(strval(...))
->all()
);
}
public function listFlagsIncludesDataProvider(): array
{
return [
'admin_sees_all' => [1, [1, 2, 3, 4, 5, 6, 7, 8]],
'user_with_general_permission_sees_where_unrestricted_tag' => [2, [6, 7, 8]],
'user_with_tag1_permission_sees_tag1_flags' => [3, [1, 2, 3, 4, 5]],
'normal_user_sees_none' => [4, []],
'normal_user_sees_own' => [5, [2, 7, 4, 8]],
];
}
}

View File

@ -9,16 +9,16 @@
namespace Flarum\Likes;
use Flarum\Api\Controller;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Extend;
use Flarum\Likes\Api\LoadLikesRelationship;
use Flarum\Likes\Api\PostResourceFields;
use Flarum\Likes\Event\PostWasLiked;
use Flarum\Likes\Event\PostWasUnliked;
use Flarum\Likes\Notification\PostLikedBlueprint;
use Flarum\Likes\Query\LikedByFilter;
use Flarum\Likes\Query\LikedFilter;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post;
use Flarum\Search\Database\DatabaseSearchDriver;
@ -39,43 +39,28 @@ return [
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Notification())
->type(PostLikedBlueprint::class, PostSerializer::class, ['alert']),
->type(PostLikedBlueprint::class, ['alert']),
(new Extend\ApiSerializer(PostSerializer::class))
->hasMany('likes', BasicUserSerializer::class)
->attribute('canLike', function (PostSerializer $serializer, $model) {
return (bool) $serializer->getActor()->can('like', $model);
})
->attribute('likesCount', function (PostSerializer $serializer, $model) {
return $model->getAttribute('likes_count') ?: 0;
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class)
->endpoint(
[Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class, Endpoint\Update::class],
function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\EndpointInterface {
return $endpoint->addDefaultInclude(['likes']);
}
),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\EndpointInterface {
return $endpoint->addDefaultInclude(['posts.likes']);
}),
(new Extend\ApiController(Controller\ShowDiscussionController::class))
->addInclude('posts.likes')
->loadWhere('posts.likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ListPostsController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ShowPostController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\CreatePostController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\UpdatePostController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\Event())
->listen(PostWasLiked::class, Listener\SendNotificationWhenPostIsLiked::class)
->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class)
->subscribe(Listener\SaveLikesToDatabase::class),
->listen(Deleted::class, function (Deleted $event) {
$event->post->likes()->detach();
}),
(new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(PostSearcher::class, LikedByFilter::class)

View File

@ -1,65 +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\Likes\Api;
use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
use Psr\Http\Message\ServerRequestInterface;
class LoadLikesRelationship
{
public static int $maxLikes = 4;
public static function mutateRelation(BelongsToMany $query, ServerRequestInterface $request): void
{
$actor = RequestUtil::getActor($request);
$grammar = $query->getQuery()->getGrammar();
$query
// So that we can tell if the current user has liked the post.
->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc')
// Limiting a relationship results is only possible because
// the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit
// trait.
->limit(self::$maxLikes);
}
/**
* Called using the @see ApiController::prepareDataForSerialization extender.
*/
public static function countRelation(AbstractSerializeController $controller, mixed $data): array
{
$loadable = null;
if ($data instanceof Discussion) {
// We do this because the ShowDiscussionController manipulates the posts
// in a way that some of them are just ids.
$loadable = $data->posts->filter(function ($post) {
return $post instanceof Post;
});
} elseif ($data instanceof Collection) {
$loadable = $data;
} elseif ($data instanceof Post) {
$loadable = $data->newCollection([$data]);
}
if ($loadable) {
$loadable->loadCount('likes');
}
return [];
}
}

View File

@ -0,0 +1,65 @@
<?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\Likes\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Likes\Event\PostWasLiked;
use Flarum\Likes\Event\PostWasUnliked;
use Flarum\Post\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
class PostResourceFields
{
public static int $maxLikes = 4;
public function __invoke(): array
{
return [
Schema\Boolean::make('isLiked')
->visible(false)
->writable(fn (Post $post, Context $context) => $context->getActor()->can('like', $post))
->set(function (Post $post, bool $liked, Context $context) {
$actor = $context->getActor();
$currentlyLiked = $post->likes()->where('user_id', $actor->id)->exists();
if ($liked && ! $currentlyLiked) {
$post->likes()->attach($actor->id);
$post->raise(new PostWasLiked($post, $actor));
} elseif ($currentlyLiked) {
$post->likes()->detach($actor->id);
$post->raise(new PostWasUnliked($post, $actor));
}
}),
Schema\Boolean::make('canLike')
->get(fn (Post $post, Context $context) => $context->getActor()->can('like', $post)),
Schema\Integer::make('likesCount')
->countRelation('likes'),
Schema\Relationship\ToMany::make('likes')
->type('users')
->includable()
->constrain(function (Builder $query, Context $context) {
$actor = $context->getActor();
$grammar = $query->getQuery()->getGrammar();
// So that we can tell if the current user has liked the post.
$query
->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc')
->limit(static::$maxLikes);
}),
];
}
}

View File

@ -1,55 +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\Likes\Listener;
use Flarum\Likes\Event\PostWasLiked;
use Flarum\Likes\Event\PostWasUnliked;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Event\Saving;
use Illuminate\Contracts\Events\Dispatcher;
class SaveLikesToDatabase
{
public function subscribe(Dispatcher $events): void
{
$events->listen(Saving::class, $this->whenPostIsSaving(...));
$events->listen(Deleted::class, $this->whenPostIsDeleted(...));
}
public function whenPostIsSaving(Saving $event): void
{
$post = $event->post;
$data = $event->data;
if ($post->exists && isset($data['attributes']['isLiked'])) {
$actor = $event->actor;
$liked = (bool) $data['attributes']['isLiked'];
$actor->assertCan('like', $post);
$currentlyLiked = $post->likes()->where('user_id', $actor->id)->exists();
if ($liked && ! $currentlyLiked) {
$post->likes()->attach($actor->id);
$post->raise(new PostWasLiked($post, $actor));
} elseif ($currentlyLiked) {
$post->likes()->detach($actor->id);
$post->raise(new PostWasUnliked($post, $actor));
}
}
}
public function whenPostIsDeleted(Deleted $event): void
{
$event->post->likes()->detach();
}
}

View File

@ -76,7 +76,7 @@ class LikePostTest extends TestCase
$post = CommentPost::query()->find($postId);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertNotNull($post->likes->where('id', $authenticatedAs)->first(), $message);
}
@ -96,7 +96,7 @@ class LikePostTest extends TestCase
$post = CommentPost::query()->find($postId);
$this->assertEquals(403, $response->getStatusCode(), $message);
$this->assertContainsEquals($response->getStatusCode(), [401, 403], $message);
$this->assertNull($post->likes->where('id', $authenticatedAs)->first());
}

View File

@ -12,7 +12,7 @@ namespace Flarum\Likes\Tests\integration\api\discussions;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Likes\Api\LoadLikesRelationship;
use Flarum\Likes\Api\PostResourceFields;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
@ -135,7 +135,7 @@ class ListPostsTest extends TestCase
$likes = $data['relationships']['likes']['data'];
// Only displays a limited amount of likes
$this->assertCount(LoadLikesRelationship::$maxLikes, $likes);
$this->assertCount(PostResourceFields::$maxLikes, $likes);
// Displays the correct count of likes
$this->assertEquals(11, $data['attributes']['likesCount']);
// Of the limited amount of likes, the actor always appears
@ -162,7 +162,7 @@ class ListPostsTest extends TestCase
$likes = $data[0]['relationships']['likes']['data'];
// Only displays a limited amount of likes
$this->assertCount(LoadLikesRelationship::$maxLikes, $likes);
$this->assertCount(PostResourceFields::$maxLikes, $likes);
// Displays the correct count of likes
$this->assertEquals(11, $data[0]['attributes']['likesCount']);
// Of the limited amount of likes, the actor always appears
@ -173,7 +173,7 @@ class ListPostsTest extends TestCase
* @dataProvider likesIncludeProvider
* @test
*/
public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include)
public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(?string $include)
{
// Show discussion endpoint
$response = $this->send(
@ -184,22 +184,27 @@ class ListPostsTest extends TestCase
])
);
$included = json_decode($response->getBody()->getContents(), true)['included'];
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$included = json_decode($body, true)['included'] ?? [];
$likes = collect($included)
->where('type', 'posts')
->where('id', 101)
->first()['relationships']['likes']['data'];
->first()['relationships']['likes']['data'] ?? null;
// Only displays a limited amount of likes
$this->assertCount(LoadLikesRelationship::$maxLikes, $likes);
$this->assertNotNull($likes, $body);
$this->assertCount(PostResourceFields::$maxLikes, $likes);
// Displays the correct count of likes
$this->assertEquals(11, collect($included)
->where('type', 'posts')
->where('id', 101)
->first()['attributes']['likesCount']);
->first()['attributes']['likesCount'] ?? null, $body);
// Of the limited amount of likes, the actor always appears
$this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id'));
$this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id'), $body);
}
public function likesIncludeProvider(): array
@ -207,7 +212,7 @@ class ListPostsTest extends TestCase
return [
['posts,posts.likes'],
['posts.likes'],
[''],
[null],
];
}
}

View File

@ -7,10 +7,10 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Context;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend;
use Flarum\Lock\Access;
@ -33,24 +33,38 @@ return [
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Notification())
->type(DiscussionLockedBlueprint::class, BasicDiscussionSerializer::class, ['alert']),
->type(DiscussionLockedBlueprint::class, ['alert']),
(new Extend\Model(Discussion::class))
->cast('is_locked', 'bool'),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->attribute('isLocked', function (DiscussionSerializer $serializer, Discussion $discussion) {
return $discussion->is_locked;
})
->attribute('canLock', function (DiscussionSerializer $serializer, Discussion $discussion) {
return $serializer->getActor()->can('lock', $discussion);
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(fn () => [
Schema\Boolean::make('isLocked')
->writable(fn (Discussion $discussion, Context $context) => $context->getActor()->can('lock', $discussion))
->set(function (Discussion $discussion, bool $isLocked, Context $context) {
$actor = $context->getActor();
if ($discussion->is_locked === $isLocked) {
return;
}
$discussion->is_locked = $isLocked;
$discussion->raise(
$discussion->is_locked
? new DiscussionWasLocked($discussion, $actor)
: new DiscussionWasUnlocked($discussion, $actor)
);
}),
Schema\Boolean::make('canLock')
->get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('lock', $discussion)),
]),
(new Extend\Post())
->type(DiscussionLockedPost::class),
(new Extend\Event())
->listen(Saving::class, Listener\SaveLockedToDatabase::class)
->listen(DiscussionWasLocked::class, Listener\CreatePostWhenDiscussionIsLocked::class)
->listen(DiscussionWasUnlocked::class, Listener\CreatePostWhenDiscussionIsUnlocked::class),

View File

@ -1,40 +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\Lock\Listener;
use Flarum\Discussion\Event\Saving;
use Flarum\Lock\Event\DiscussionWasLocked;
use Flarum\Lock\Event\DiscussionWasUnlocked;
class SaveLockedToDatabase
{
public function handle(Saving $event): void
{
if (isset($event->data['attributes']['isLocked'])) {
$isLocked = (bool) $event->data['attributes']['isLocked'];
$discussion = $event->discussion;
$actor = $event->actor;
$actor->assertCan('lock', $discussion);
if ((bool) $discussion->is_locked === $isLocked) {
return;
}
$discussion->is_locked = $isLocked;
$discussion->raise(
$discussion->is_locked
? new DiscussionWasLocked($discussion, $actor)
: new DiscussionWasUnlocked($discussion, $actor)
);
}
}
}

View File

@ -9,16 +9,14 @@
namespace Flarum\Mentions;
use Flarum\Api\Controller;
use Flarum\Api\Serializer\BasicPostSerializer;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Extend;
use Flarum\Group\Group;
use Flarum\Mentions\Api\LoadMentionedByRelationship;
use Flarum\Mentions\Api\PostResourceFields;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Event\Hidden;
use Flarum\Post\Event\Posted;
@ -27,7 +25,6 @@ use Flarum\Post\Event\Revised;
use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\User\User;
return [
@ -60,50 +57,49 @@ return [
->namespace('flarum-mentions', __DIR__.'/views'),
(new Extend\Notification())
->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']),
->type(Notification\PostMentionedBlueprint::class, ['alert'])
->type(Notification\UserMentionedBlueprint::class, ['alert'])
->type(Notification\GroupMentionedBlueprint::class, ['alert']),
(new Extend\ApiSerializer(BasicPostSerializer::class))
->hasMany('mentionedBy', BasicPostSerializer::class)
->hasMany('mentionsPosts', BasicPostSerializer::class)
->hasMany('mentionsUsers', BasicUserSerializer::class)
->hasMany('mentionsGroups', GroupSerializer::class)
->attribute('mentionedByCount', function (BasicPostSerializer $serializer, Post $post) {
// Only if it was eager loaded.
return $post->getAttribute('mentioned_by_count') ?? 0;
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class)
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface {
return $endpoint->addDefaultInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']);
})
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoad(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups']);
}),
(new Extend\ApiController(Controller\ShowDiscussionController::class))
->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
->load([
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user',
'posts.mentionsPosts.discussion', 'posts.mentionsGroups'
])
->loadWhere('posts.mentionedBy', LoadMentionedByRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ListDiscussionsController::class))
->load([
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoadWhenIncluded([
'firstPost' => [
'firstPost.mentionsUsers', 'firstPost.mentionsPosts',
'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups',
],
'lastPost' => [
'lastPost.mentionsUsers', 'lastPost.mentionsPosts',
'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups',
],
]);
})
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show {
return $endpoint->addDefaultInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
->eagerLoadWhenIncluded([
'posts' => [
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user',
'posts.mentionsPosts.discussion', 'posts.mentionsGroups'
],
]);
}),
(new Extend\ApiResource(Resource\UserResource::class))
->fields(fn () => [
Schema\Boolean::make('canMentionGroups')
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id)
->get(fn (User $user) => $user->can('mentionGroups')),
]),
(new Extend\ApiController(Controller\ShowPostController::class))
->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion'])
// We wouldn't normally need to eager load on a single model,
// but we do so here for visibility scoping.
->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ListPostsController::class))
->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion'])
->load(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups'])
->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)),
(new Extend\Settings)
->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'),
@ -119,11 +115,6 @@ return [
->addFilter(PostSearcher::class, Filter\MentionedFilter::class)
->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class),
(new Extend\ApiSerializer(CurrentUserSerializer::class))
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool {
return $user->can('mentionGroups');
}),
// Tag mentions
(new Extend\Conditional())
->whenExtensionEnabled('flarum-tags', fn () => [
@ -131,18 +122,23 @@ return [
->render(Formatter\FormatTagMentions::class)
->unparse(Formatter\UnparseTagMentions::class),
(new Extend\ApiSerializer(BasicPostSerializer::class))
->hasMany('mentionsTags', TagSerializer::class),
(new Extend\ApiController(Controller\ShowDiscussionController::class))
->load(['posts.mentionsTags']),
(new Extend\ApiController(Controller\ListDiscussionsController::class))
->load([
'firstPost.mentionsTags', 'lastPost.mentionsTags',
(new Extend\ApiResource(Resource\PostResource::class))
->fields(fn () => [
Schema\Relationship\ToMany::make('mentionsTags')
->type('tags'),
]),
(new Extend\ApiController(Controller\ListPostsController::class))
->load(['mentionsTags']),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show {
return $endpoint->eagerLoadWhenIncluded(['posts' => ['posts.mentionsTags']]);
})
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoadWhenIncluded(['firstPost' => ['firstPost.mentionsTags'], 'lastPost' => ['lastPost.mentionsTags']]);
}),
(new Extend\ApiResource(Resource\PostResource::class))
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface {
return $endpoint->eagerLoad(['mentionsTags']);
}),
]),
];

View File

@ -1,82 +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\Mentions\Api;
use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Psr\Http\Message\ServerRequestInterface;
/**
* Apply visibility permissions to API data's mentionedBy relationship.
* And limit mentionedBy to 3 posts only for performance reasons.
*/
class LoadMentionedByRelationship
{
public static int $maxMentionedBy = 4;
public static function mutateRelation(BelongsToMany $query, ServerRequestInterface $request): void
{
$actor = RequestUtil::getActor($request);
$query
->with(['mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsUsers'])
->whereVisibleTo($actor)
->oldest('posts.created_at')
// Limiting a relationship results is only possible because
// the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit
// trait.
->limit(self::$maxMentionedBy);
}
/**
* Called using the @see ApiController::prepareDataForSerialization extender.
*/
public static function countRelation(AbstractSerializeController $controller, mixed $data, ServerRequestInterface $request): array
{
$actor = RequestUtil::getActor($request);
$loadable = null;
if ($data instanceof Discussion) {
// We do this because the ShowDiscussionController manipulates the posts
// in a way that some of them are just ids.
$loadable = $data->posts->filter(function ($post) {
return $post instanceof Post;
});
// firstPost and lastPost might have been included in the API response,
// so we have to make sure counts are also loaded for them.
if ($data->firstPost) {
$loadable->push($data->firstPost);
}
if ($data->lastPost) {
$loadable->push($data->lastPost);
}
} elseif ($data instanceof Collection) {
$loadable = $data;
} elseif ($data instanceof Post) {
$loadable = $data->newCollection([$data]);
}
if ($loadable) {
$loadable->loadCount([
'mentionedBy' => function ($query) use ($actor) {
return $query->whereVisibleTo($actor);
}
]);
}
return [];
}
}

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Mentions\Api;
use Flarum\Api\Schema;
use Illuminate\Database\Eloquent\Builder;
class PostResourceFields
{
public static int $maxMentionedBy = 4;
public function __invoke(): array
{
return [
Schema\Integer::make('mentionedByCount')
->countRelation('mentionedBy'),
Schema\Relationship\ToMany::make('mentionedBy')
->type('posts')
->includable()
->constrain(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)),
Schema\Relationship\ToMany::make('mentionsPosts')
->type('posts'),
Schema\Relationship\ToMany::make('mentionsUsers')
->type('users'),
Schema\Relationship\ToMany::make('mentionsGroups')
->type('groups'),
];
}
}

View File

@ -93,11 +93,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"InvalidGroup"#g99',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
]
],
],
@ -168,11 +169,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Mods"#g4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
]
]
]
@ -200,11 +202,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Admins"#g1 @"Mods"#g4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
]
]
]
@ -234,11 +237,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Members"#g3 @"Guests"#g2',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
]
]
]
@ -290,11 +294,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 3,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Mods"#g4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -321,11 +326,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 4,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Mods"#g4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -352,11 +358,12 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 4,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Ninjas"#g10',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -383,6 +390,7 @@ class GroupMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'New content with @"Mods"#g4 mention',
],

View File

@ -11,7 +11,7 @@ namespace Flarum\Mentions\Tests\integration\api\discussions;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Mentions\Api\LoadMentionedByRelationship;
use Flarum\Mentions\Api\PostResourceFields;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
@ -170,7 +170,7 @@ class ListPostsTest extends TestCase
$mentionedBy = $data['relationships']['mentionedBy']['data'];
// Only displays a limited amount of mentioned by posts
$this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy);
$this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy);
// Of the limited amount of mentioned by posts, they must be visible to the actor
$this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id'));
}
@ -190,14 +190,14 @@ class ListPostsTest extends TestCase
])
);
$data = json_decode($response->getBody()->getContents(), true)['data'];
$data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? [];
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body);
$mentionedBy = $data[0]['relationships']['mentionedBy']['data'];
// Only displays a limited amount of mentioned by posts
$this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy);
$this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy);
// Of the limited amount of mentioned by posts, they must be visible to the actor
$this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id'));
}
@ -206,7 +206,7 @@ class ListPostsTest extends TestCase
* @dataProvider mentionedByIncludeProvider
* @test
*/
public function mentioned_by_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include)
public function mentioned_by_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(?string $include)
{
$this->prepareMentionedByData();
@ -219,15 +219,18 @@ class ListPostsTest extends TestCase
])
);
$included = json_decode($response->getBody()->getContents(), true)['included'];
$included = json_decode($body = $response->getBody()->getContents(), true)['included'] ?? [];
$this->assertEquals(200, $response->getStatusCode(), $body);
$mentionedBy = collect($included)
->where('type', 'posts')
->where('id', 101)
->first()['relationships']['mentionedBy']['data'];
->first()['relationships']['mentionedBy']['data'] ?? null;
$this->assertNotNull($mentionedBy, 'Mentioned by relation not included');
// Only displays a limited amount of mentioned by posts
$this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy);
$this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy);
// Of the limited amount of mentioned by posts, they must be visible to the actor
$this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id'));
}
@ -237,7 +240,7 @@ class ListPostsTest extends TestCase
return [
['posts,posts.mentionedBy'],
['posts.mentionedBy'],
[''],
[null],
];
}
@ -253,10 +256,54 @@ class ListPostsTest extends TestCase
])
);
$data = json_decode($response->getBody()->getContents(), true)['data'];
$data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? [];
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body);
$this->assertEquals(0, $data['attributes']['mentionedByCount']);
}
/** @test */
public function mentioned_by_count_works_on_show_endpoint()
{
$this->prepareMentionedByData();
// List posts endpoint
$response = $this->send(
$this->request('GET', '/api/posts/101', [
'authenticatedAs' => 1,
])
);
$data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? [];
$this->assertEquals(200, $response->getStatusCode(), $body);
$this->assertEquals(10, $data['attributes']['mentionedByCount']);
}
/** @test */
public function mentioned_by_count_works_on_list_endpoint()
{
$this->prepareMentionedByData();
// List posts endpoint
$response = $this->send(
$this->request('GET', '/api/posts', [
'authenticatedAs' => 1,
])->withQueryParams([
'filter' => ['discussion' => 100],
])
);
$data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? [];
$this->assertEquals(200, $response->getStatusCode(), $body);
$post101 = collect($data)->where('id', 101)->first();
$post112 = collect($data)->where('id', 112)->first();
$this->assertEquals(10, $post101['attributes']['mentionedByCount']);
$this->assertEquals(0, $post112['attributes']['mentionedByCount']);
}
}

View File

@ -83,11 +83,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@potato#4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -114,11 +115,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"POTATO$"#p4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -145,11 +147,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"potato"#p50',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -176,11 +179,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@“POTATO$”#p4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -207,11 +211,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"franzofflarum"#p215',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -238,11 +243,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"TOBY$"#p5 @"flarum"#2015 @"franzofflarum"#220 @"POTATO$"#3 @"POTATO$"#p4',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -385,11 +391,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad "#p6 User"#p9',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -437,11 +444,12 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad _ User"#p9',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -468,6 +476,7 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad _ User"#p9',
],
@ -496,6 +505,7 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad _ User"#p9',
],
@ -524,6 +534,7 @@ class PostMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"acme"#p11',
],

View File

@ -72,11 +72,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#flarum',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -100,11 +101,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#戦い',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -129,11 +131,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#franzofflarum',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -159,11 +162,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#test',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -187,11 +191,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 3,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#dev',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -215,11 +220,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#dev',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -243,11 +249,12 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#test #flarum #support #laravel #franzofflarum',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -369,6 +376,7 @@ class TagMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '#laravel',
],

View File

@ -74,11 +74,12 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@potato',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -107,11 +108,12 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@potato',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]],
],
],
],
@ -138,6 +140,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"POTATO$"#3',
],
@ -169,6 +172,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@“POTATO$”#3',
],
@ -200,6 +204,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"franzofflarum"#82',
],
@ -231,6 +236,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"TOBY$"#4 @"POTATO$"#p4 @"franzofflarum"#82 @"POTATO$"#3',
],
@ -284,6 +290,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"potato_"#3',
],
@ -314,6 +321,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"potato_"#3',
],
@ -369,6 +377,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad "#p6 User"#5',
],
@ -421,6 +430,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad _ User"#5',
],
@ -452,6 +462,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad _ User"#5',
],
@ -480,6 +491,7 @@ class UserMentionsTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '@"Bad _ User"#5',
],

View File

@ -9,14 +9,13 @@
namespace Flarum\Nicknames;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Api\Resource;
use Flarum\Extend;
use Flarum\Nicknames\Access\UserPolicy;
use Flarum\Nicknames\Api\UserResourceFields;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\User\Event\Saving;
use Flarum\User\Search\UserSearcher;
use Flarum\User\User;
use Flarum\User\UserValidator;
return [
(new Extend\Frontend('forum'))
@ -33,13 +32,9 @@ return [
(new Extend\User())
->displayNameDriver('nickname', NicknameDriver::class),
(new Extend\Event())
->listen(Saving::class, SaveNicknameToDatabase::class),
(new Extend\ApiSerializer(UserSerializer::class))
->attribute('canEditNickname', function (UserSerializer $serializer, User $user) {
return $serializer->getActor()->can('editNickname', $user);
}),
(new Extend\ApiResource(Resource\UserResource::class))
->fields(UserResourceFields::class)
->field('username', UserResourceFields::username(...)),
(new Extend\Settings())
->default('flarum-nicknames.set_on_registration', true)
@ -50,9 +45,6 @@ return [
->serializeToForum('setNicknameOnRegistration', 'flarum-nicknames.set_on_registration', 'boolval')
->serializeToForum('randomizeUsernameOnRegistration', 'flarum-nicknames.random_username', 'boolval'),
(new Extend\Validator(UserValidator::class))
->configure(AddNicknameValidation::class),
(new Extend\SearchDriver(DatabaseSearchDriver::class))
->setFulltext(UserSearcher::class, NicknameFullTextFilter::class),

View File

@ -1,50 +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\Nicknames;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\UserValidator;
use Illuminate\Validation\Validator;
class AddNicknameValidation
{
public function __construct(
protected SettingsRepositoryInterface $settings,
protected TranslatorInterface $translator
) {
}
public function __invoke(UserValidator $flarumValidator, Validator $validator): void
{
$idSuffix = $flarumValidator->getUser() ? ','.$flarumValidator->getUser()->id : '';
$rules = $validator->getRules();
$rules['nickname'] = [
function ($attribute, $value, $fail) {
$regex = $this->settings->get('flarum-nicknames.regex');
if ($regex && ! preg_match_all("/$regex/", $value)) {
$fail($this->translator->trans('flarum-nicknames.api.invalid_nickname_message'));
}
},
'min:'.$this->settings->get('flarum-nicknames.min'),
'max:'.$this->settings->get('flarum-nicknames.max'),
'nullable'
];
if ($this->settings->get('flarum-nicknames.unique')) {
$rules['nickname'][] = 'unique:users,username'.$idSuffix;
$rules['nickname'][] = 'unique:users,nickname'.$idSuffix;
$rules['username'][] = 'unique:users,nickname'.$idSuffix;
}
$validator->setRules($rules);
}
}

View File

@ -0,0 +1,61 @@
<?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\Nicknames\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
class UserResourceFields
{
public function __construct(
protected SettingsRepositoryInterface $settings,
protected TranslatorInterface $translator
) {
}
public function __invoke(): array
{
$regex = $this->settings->get('flarum-nicknames.regex');
if (! empty($regex)) {
$regex = "/$regex/";
}
return [
Schema\Str::make('nickname')
->visible(false)
->writable(function (User $user, Context $context) {
return $context->getActor()->can('editNickname', $user);
})
->nullable()
->regex($regex ?? '', ! empty($regex))
->minLength($this->settings->get('flarum-nicknames.min'))
->maxLength($this->settings->get('flarum-nicknames.max'))
->unique('users', 'nickname', true, (bool) $this->settings->get('flarum-nicknames.unique'))
->unique('users', 'username', true, (bool) $this->settings->get('flarum-nicknames.unique'))
->validationMessages([
'nickname.regex' => $this->translator->trans('flarum-nicknames.api.invalid_nickname_message'),
])
->set(function (User $user, ?string $nickname) {
$user->nickname = $user->username === $nickname ? null : $nickname;
}),
Schema\Boolean::make('canEditNickname')
->get(fn (User $user, Context $context) => $context->getActor()->can('editNickname', $user)),
];
}
public static function username(Schema\Str $field): Schema\Str
{
return $field->unique('users', 'nickname', true, (bool) resolve(SettingsRepositoryInterface::class)->get('flarum-nicknames.unique'));
}
}

View File

@ -1,40 +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\Nicknames;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Event\Saving;
use Illuminate\Support\Arr;
class SaveNicknameToDatabase
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function handle(Saving $event): void
{
$user = $event->user;
$data = $event->data;
$actor = $event->actor;
$attributes = Arr::get($data, 'attributes', []);
if (isset($attributes['nickname'])) {
$actor->assertCan('editNickname', $user);
$nickname = $attributes['nickname'];
// If the user sets their nickname back to the username
// set the nickname to null so that it just falls back to the username
$user->nickname = $user->username === $nickname ? null : $nickname;
}
}
}

View File

@ -10,6 +10,7 @@
namespace Flarum\Nicknames\Tests\integration;
use Flarum\Group\Group;
use Flarum\Locale\TranslatorInterface;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
@ -45,6 +46,7 @@ class UpdateTest extends TestCase
'authenticatedAs' => 2,
'json' => [
'data' => [
'type' => 'users',
'attributes' => [
'nickname' => 'new nickname',
],
@ -53,7 +55,7 @@ class UpdateTest extends TestCase
])
);
$this->assertEquals(403, $response->getStatusCode());
$this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents());
}
/**
@ -72,6 +74,7 @@ class UpdateTest extends TestCase
'authenticatedAs' => 2,
'json' => [
'data' => [
'type' => 'users',
'attributes' => [
'nickname' => 'new nickname',
],
@ -80,8 +83,36 @@ class UpdateTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals('new nickname', User::find(2)->nickname);
}
/**
* @test
*/
public function cant_edit_nickname_if_invalid_regex()
{
$this->setting('flarum-nicknames.set_on_registration', true);
$this->setting('flarum-nicknames.regex', '^[A-z]+$');
$response = $this->send(
$this->request('PATCH', '/api/users/2', [
'authenticatedAs' => 2,
'json' => [
'data' => [
'type' => 'users',
'attributes' => [
'nickname' => '007',
],
],
],
])
);
$body = $response->getBody()->getContents();
$this->assertEquals(422, $response->getStatusCode(), $body);
$this->assertStringContainsString($this->app()->getContainer()->make(TranslatorInterface::class)->trans('flarum-nicknames.api.invalid_nickname_message'), $body);
}
}

View File

@ -44,7 +44,7 @@ class RegisterTest extends TestCase
])
);
$this->assertEquals(201, $response->getStatusCode());
$this->assertEquals(201, $response->getStatusCode(), $response->getBody()->getContents());
/** @var User $user */
$user = User::where('username', 'test')->firstOrFail();
@ -72,7 +72,7 @@ class RegisterTest extends TestCase
])
);
$this->assertEquals(403, $response->getStatusCode());
$this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents());
}
/**
@ -94,7 +94,7 @@ class RegisterTest extends TestCase
])
);
$this->assertEquals(422, $response->getStatusCode());
$this->assertEquals(422, $response->getStatusCode(), $response->getBody()->getContents());
}
/**
@ -116,6 +116,6 @@ class RegisterTest extends TestCase
])
);
$this->assertEquals(201, $response->getStatusCode());
$this->assertEquals(201, $response->getStatusCode(), $response->getBody()->getContents());
}
}

View File

@ -10,6 +10,7 @@
namespace Flarum\ExtensionManager;
use Flarum\Extend;
use Flarum\ExtensionManager\Api\Resource\TaskResource;
use Flarum\Foundation\Paths;
use Flarum\Frontend\Document;
use Illuminate\Contracts\Queue\Queue;
@ -25,9 +26,10 @@ return [
->post('/extension-manager/minor-update', 'extension-manager.minor-update', Api\Controller\MinorUpdateController::class)
->post('/extension-manager/major-update', 'extension-manager.major-update', Api\Controller\MajorUpdateController::class)
->post('/extension-manager/global-update', 'extension-manager.global-update', Api\Controller\GlobalUpdateController::class)
->get('/extension-manager-tasks', 'extension-manager.tasks.index', Api\Controller\ListTasksController::class)
->post('/extension-manager/composer', 'extension-manager.composer', Api\Controller\ConfigureComposerController::class),
new Extend\ApiResource(TaskResource::class),
(new Extend\Frontend('admin'))
->css(__DIR__.'/less/admin.less')
->js(__DIR__.'/js/dist/admin.js')

View File

@ -1,7 +1,8 @@
import app from 'flarum/admin/app';
import type Mithril from 'mithril';
import Component, { type ComponentAttrs } from 'flarum/common/Component';
import { CommonSettingsItemOptions, type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage';
import { type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage';
import FormGroup, { type CommonFieldOptions } from 'flarum/common/components/FormGroup';
import AdminPage from 'flarum/admin/components/AdminPage';
import type ItemList from 'flarum/common/utils/ItemList';
import Stream from 'flarum/common/utils/Stream';
@ -49,8 +50,8 @@ export default abstract class ConfigureJson<CustomAttrs extends IConfigureJson =
];
}
customSettingComponents(): ItemList<(attributes: CommonSettingsItemOptions) => Mithril.Children> {
return AdminPage.prototype.customSettingComponents();
customSettingComponents(): ItemList<(attributes: CommonFieldOptions) => Mithril.Children> {
return FormGroup.prototype.customFieldComponents();
}
setting(key: string) {

View File

@ -22,7 +22,7 @@ export default class QueueState {
return app.store.find<Task[]>('extension-manager-tasks', params || {}).then((data) => {
this.tasks = data;
this.total = data.payload.meta?.total;
this.total = data.payload.meta?.total || 0;
m.redraw();

View File

@ -1,58 +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\ExtensionManager\Api\Controller;
use Flarum\Api\Controller\AbstractListController;
use Flarum\ExtensionManager\Api\Serializer\TaskSerializer;
use Flarum\ExtensionManager\Task\Task;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListTasksController extends AbstractListController
{
public ?string $serializer = TaskSerializer::class;
public function __construct(
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$actor->assertAdmin();
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$results = Task::query()
->latest('id')
->offset($offset)
->limit($limit)
->get();
$total = Task::query()->count();
$document->addMeta('total', (string) $total);
$document->addPaginationLinks(
$this->url->to('api')->route('extension-manager.tasks.index'),
$request->getQueryParams(),
$offset,
$limit,
$total
);
return $results;
}
}

View File

@ -0,0 +1,60 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\ExtensionManager\Api\Resource;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\ExtensionManager\Task\Task;
class TaskResource extends AbstractDatabaseResource
{
public function type(): string
{
return 'package-manager-tasks';
}
public function model(): string
{
return Task::class;
}
public function endpoints(): array
{
return [
Endpoint\Index::make()
->defaultSort('-createdAt')
->paginate(),
];
}
public function fields(): array
{
return [
Schema\Str::make('status'),
Schema\Str::make('operation'),
Schema\Str::make('command'),
Schema\Str::make('package'),
Schema\Str::make('output'),
Schema\DateTime::make('createdAt'),
Schema\DateTime::make('startedAt'),
Schema\DateTime::make('finishedAt'),
Schema\Number::make('peakMemoryUsed'),
];
}
public function sorts(): array
{
return [
SortColumn::make('createdAt'),
];
}
}

View File

@ -1,50 +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\ExtensionManager\Api\Serializer;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\ExtensionManager\Task\Task;
use InvalidArgumentException;
class TaskSerializer extends AbstractSerializer
{
/**
* {@inheritdoc}
*/
protected $type = 'extension-manager-tasks';
/**
* {@inheritdoc}
*
* @param Task $model
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes($model): array
{
if (! ($model instanceof Task)) {
throw new InvalidArgumentException(
get_class($this).' can only serialize instances of '.Task::class
);
}
return [
'status' => $model->status,
'operation' => $model->operation,
'command' => $model->command,
'package' => $model->package,
'output' => $model->output,
'guessedCause' => $model->guessed_cause,
'createdAt' => $model->created_at,
'startedAt' => $model->started_at,
'finishedAt' => $model->finished_at,
'peakMemoryUsed' => $model->peak_memory_used,
];
}
}

View File

@ -1,34 +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\ExtensionManager\Task;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class TaskRepository
{
/**
* @return Builder
*/
public function query()
{
return Task::query();
}
/**
* @param int $id
* @param User $actor
* @return Task
*/
public function findOrFail($id, User $actor = null): Task
{
return Task::findOrFail($id);
}
}

View File

@ -12,6 +12,7 @@ namespace Flarum\Statistics\Api\Controller;
use Carbon\Carbon;
use DateTime;
use Flarum\Discussion\Discussion;
use Flarum\Http\Exception\InvalidParameterException;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Flarum\Post\RegisteredTypesScope;
@ -24,7 +25,6 @@ use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApi\Exception\InvalidParameterException;
class ShowStatisticsData implements RequestHandlerInterface
{

View File

@ -7,17 +7,16 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Controller\ListDiscussionsController;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Sticky\Api\DiscussionResourceFields;
use Flarum\Sticky\Event\DiscussionWasStickied;
use Flarum\Sticky\Event\DiscussionWasUnstickied;
use Flarum\Sticky\Listener;
use Flarum\Sticky\Listener\SaveStickyToDatabase;
use Flarum\Sticky\PinStickiedDiscussionsToTop;
use Flarum\Sticky\Post\DiscussionStickiedPost;
use Flarum\Sticky\Query\StickyFilter;
@ -27,30 +26,24 @@ return [
->js(__DIR__.'/js/dist/forum.js')
->css(__DIR__.'/less/forum.less'),
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js'),
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Model(Discussion::class))
->cast('is_sticky', 'bool'),
(new Extend\Post())
->type(DiscussionStickiedPost::class),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->attribute('isSticky', function (DiscussionSerializer $serializer, Discussion $discussion) {
return (bool) $discussion->is_sticky;
})
->attribute('canSticky', function (DiscussionSerializer $serializer, $discussion) {
return (bool) $serializer->getActor()->can('sticky', $discussion);
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(DiscussionResourceFields::class)
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->addDefaultInclude(['firstPost']);
}),
(new Extend\ApiController(ListDiscussionsController::class))
->addInclude('firstPost'),
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js'),
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Event())
->listen(Saving::class, SaveStickyToDatabase::class)
->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied'])
->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']),

View File

@ -0,0 +1,48 @@
<?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\Sticky\Api;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Update;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Sticky\Event\DiscussionWasStickied;
use Flarum\Sticky\Event\DiscussionWasUnstickied;
class DiscussionResourceFields
{
public function __invoke(): array
{
return [
Schema\Boolean::make('isSticky')
->writable(function (Discussion $discussion, Context $context) {
return $context->endpoint instanceof Update
&& $context->getActor()->can('sticky', $discussion);
})
->set(function (Discussion $discussion, bool $isSticky, Context $context) {
$actor = $context->getActor();
if ($discussion->is_sticky === $isSticky) {
return;
}
$discussion->is_sticky = $isSticky;
$discussion->raise(
$discussion->is_sticky
? new DiscussionWasStickied($discussion, $actor)
: new DiscussionWasUnstickied($discussion, $actor)
);
}),
Schema\Boolean::make('canSticky')
->get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('sticky', $discussion)),
];
}
}

View File

@ -1,40 +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\Sticky\Listener;
use Flarum\Discussion\Event\Saving;
use Flarum\Sticky\Event\DiscussionWasStickied;
use Flarum\Sticky\Event\DiscussionWasUnstickied;
class SaveStickyToDatabase
{
public function handle(Saving $event): void
{
if (isset($event->data['attributes']['isSticky'])) {
$isSticky = (bool) $event->data['attributes']['isSticky'];
$discussion = $event->discussion;
$actor = $event->actor;
$actor->assertCan('sticky', $discussion);
if ((bool) $discussion->is_sticky === $isSticky) {
return;
}
$discussion->is_sticky = $isSticky;
$discussion->raise(
$discussion->is_sticky
? new DiscussionWasStickied($discussion, $actor)
: new DiscussionWasUnstickied($discussion, $actor)
);
}
}
}

View File

@ -62,11 +62,11 @@ class ListDiscussionsTest extends TestCase
$this->request('GET', '/api/discussions')
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$data = json_decode($response->getBody()->getContents(), true);
$data = json_decode($body, true);
$this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
$this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
}
/** @test */
@ -78,11 +78,11 @@ class ListDiscussionsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$data = json_decode($response->getBody()->getContents(), true);
$data = json_decode($body, true);
$this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
$this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
}
/** @test */
@ -94,11 +94,11 @@ class ListDiscussionsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$data = json_decode($response->getBody()->getContents(), true);
$data = json_decode($body, true);
$this->assertEquals([2, 4, 3, 1], Arr::pluck($data['data'], 'id'));
$this->assertEqualsCanonicalizing([2, 4, 3, 1], Arr::pluck($data['data'], 'id'));
}
/** @test */
@ -114,10 +114,10 @@ class ListDiscussionsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$data = json_decode($response->getBody()->getContents(), true);
$data = json_decode($body, true);
$this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
$this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
}
}

View File

@ -0,0 +1,91 @@
<?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\Sticky\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class StickyDiscussionsTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-sticky');
$this->prepareDatabase([
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'Muralf_', 'email' => 'muralf_@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(2), 'last_posted_at' => Carbon::now()->addMinutes(5), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(3), 'last_posted_at' => Carbon::now()->addMinute(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(4), 'last_posted_at' => Carbon::now()->addMinutes(2), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1],
],
'groups' => [
['id' => 5, 'name_singular' => 'Group', 'name_plural' => 'Groups', 'color' => 'blue'],
],
'group_user' => [
['user_id' => 2, 'group_id' => 5]
],
'group_permission' => [
['group_id' => 5, 'permission' => 'discussion.sticky'],
],
]);
}
/**
* @dataProvider stickyDataProvider
* @test
*/
public function can_sticky_if_allowed(int $actorId, bool $allowed, bool $sticky)
{
$response = $this->send(
$this->request('PATCH', '/api/discussions/1', [
'authenticatedAs' => $actorId,
'json' => [
'data' => [
'attributes' => [
'isSticky' => $sticky
]
]
]
])
);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
if ($allowed) {
$this->assertEquals(200, $response->getStatusCode(), $body);
$this->assertEquals($sticky, $json['data']['attributes']['isSticky']);
} else {
$this->assertEquals(403, $response->getStatusCode(), $body);
}
}
public static function stickyDataProvider(): array
{
return [
[1, true, true],
[1, true, false],
[2, true, true],
[2, true, false],
[3, false, true],
[3, false, false],
];
}
}

View File

@ -7,10 +7,8 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Resource;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Discussion\UserState;
@ -20,6 +18,7 @@ use Flarum\Post\Event\Hidden;
use Flarum\Post\Event\Posted;
use Flarum\Post\Event\Restored;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Subscriptions\Api\UserResourceFields;
use Flarum\Subscriptions\Filter\SubscriptionFilter;
use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage;
use Flarum\Subscriptions\Listener;
@ -48,18 +47,11 @@ return [
->namespace('flarum-subscriptions', __DIR__.'/views'),
(new Extend\Notification())
->type(NewPostBlueprint::class, BasicDiscussionSerializer::class, ['alert', 'email'])
->type(NewPostBlueprint::class, ['alert', 'email'])
->beforeSending(FilterVisiblePostsBeforeSending::class),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->attribute('subscription', function (DiscussionSerializer $serializer, Discussion $discussion) {
if ($state = $discussion->state) {
return $state->subscription;
}
}),
(new Extend\User())
->registerPreference('followAfterReply', 'boolval', false),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(UserResourceFields::class),
(new Extend\Event())
->listen(Saving::class, Listener\SaveSubscriptionToDatabase::class)
@ -75,5 +67,6 @@ return [
->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class),
(new Extend\User())
->registerPreference('followAfterReply', 'boolval', false)
->registerPreference('flarum-subscriptions.notify_for_all_posts', 'boolval', false),
];

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Subscriptions\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
class UserResourceFields
{
public function __invoke(): array
{
return [
Schema\Str::make('subscription')
->writable(fn (Discussion $discussion, Context $context) => $context->updating())
->nullable()
->get(fn (Discussion $discussion) => $discussion->state?->subscription)
->set(function (Discussion $discussion, ?string $subscription, Context $context) {
$actor = $context->getActor();
$state = $discussion->stateFor($actor);
if (! in_array($subscription, ['follow', 'ignore'])) {
$subscription = null;
}
$state->subscription = $subscription;
}),
];
}
}

View File

@ -120,6 +120,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'discussions',
'attributes' => [
'title' => 'ACME',
],
@ -134,6 +135,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'discussions',
'attributes' => [
'lastReadPostNumber' => 2,
],
@ -149,6 +151,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 2,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'reply with predetermined content for automated testing - too-obscure',
],
@ -204,6 +207,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 3,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'reply with predetermined content for automated testing - too-obscure',
],
@ -250,6 +254,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 4,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'reply with predetermined content for automated testing - too-obscure',
],
@ -271,6 +276,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 1,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'isApproved' => 1,
],
@ -310,6 +316,7 @@ class ReplyNotificationTest extends TestCase
'authenticatedAs' => 3,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'restricted-test-post',
],

View File

@ -0,0 +1,94 @@
<?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\Subscriptions\Tests\integration\api\discussions;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class SubscribeTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-subscriptions');
$this->prepareDatabase([
'users' => [
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1, 'preferences' => json_encode(['flarum-subscriptions.notify_for_all_posts' => true])],
['id' => 4, 'username' => 'acme2', 'email' => 'acme2@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'last_post_number' => 1, 'last_post_id' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'last_post_number' => 1, 'last_post_id' => 2],
['id' => 33, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 33, 'comment_count' => 6, 'last_post_number' => 6, 'last_post_id' => 38],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 33, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 34, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 2],
['id' => 35, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 3],
['id' => 36, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 4],
['id' => 37, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 5],
['id' => 38, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 6],
],
'discussion_user' => [
['discussion_id' => 1, 'user_id' => 1, 'last_read_post_number' => 1, 'subscription' => 'follow'],
['discussion_id' => 1, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => null],
['discussion_id' => 2, 'user_id' => 1, 'last_read_post_number' => 1, 'subscription' => 'follow'],
['discussion_id' => 33, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'follow'],
['discussion_id' => 33, 'user_id' => 3, 'last_read_post_number' => 1, 'subscription' => 'ignore'],
]
]);
}
/**
* @test
* @dataProvider provideStates
*/
public function can_subscribe_to_a_discussion(int $actorId, int $discussionId, ?string $newState)
{
$this->app();
$response = $this->send(
$this->request('PATCH', '/api/discussions/'.$discussionId, [
'authenticatedAs' => $actorId,
'json' => [
'data' => [
'type' => 'discussions',
'attributes' => [
'subscription' => $newState,
],
],
],
])
);
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals($newState, $this->database()->table('discussion_user')->where('discussion_id', $discussionId)->where('user_id', $actorId)->value('subscription'));
}
public static function provideStates()
{
return [
'follow' => [2, 1, 'follow'],
'ignore' => [2, 1, 'ignore'],
'null' => [2, 1, null],
];
}
}

View File

@ -7,13 +7,13 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Api\Context;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Extend;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Suspend\Access\UserPolicy;
use Flarum\Suspend\AddUserSuspendAttributes;
use Flarum\Suspend\Api\UserResourceFields;
use Flarum\Suspend\Event\Suspended;
use Flarum\Suspend\Event\Unsuspended;
use Flarum\Suspend\Listener;
@ -39,22 +39,23 @@ return [
->cast('suspend_reason', 'string')
->cast('suspend_message', 'string'),
(new Extend\ApiSerializer(UserSerializer::class))
->attributes(AddUserSuspendAttributes::class),
(new Extend\ApiResource(Resource\UserResource::class))
->fields(UserResourceFields::class),
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('canSuspendUsers', function (ForumSerializer $serializer) {
return $serializer->getActor()->hasPermission('user.suspend');
}),
(new Extend\ApiResource(Resource\ForumResource::class))
->fields(fn () => [
Schema\Boolean::make('canSuspendUsers')
->get(fn (object $model, Context $context) => $context->getActor()->hasPermission('user.suspend')),
]),
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Notification())
->type(UserSuspendedBlueprint::class, BasicUserSerializer::class, ['alert', 'email'])
->type(UserUnsuspendedBlueprint::class, BasicUserSerializer::class, ['alert', 'email']),
->type(UserSuspendedBlueprint::class, ['alert', 'email'])
->type(UserUnsuspendedBlueprint::class, ['alert', 'email']),
(new Extend\Event())
->listen(Saving::class, Listener\SaveSuspensionToDatabase::class)
->listen(Saving::class, Listener\SavingUser::class)
->listen(Suspended::class, Listener\SendNotificationWhenUserIsSuspended::class)
->listen(Unsuspended::class, Listener\SendNotificationWhenUserIsUnsuspended::class),

View File

@ -1,35 +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\Suspend;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\User\User;
class AddUserSuspendAttributes
{
public function __invoke(UserSerializer $serializer, User $user): array
{
$attributes = [];
$canSuspend = $serializer->getActor()->can('suspend', $user);
if ($canSuspend) {
$attributes['suspendReason'] = $user->suspend_reason;
}
if ($serializer->getActor()->id === $user->id || $canSuspend) {
$attributes['suspendMessage'] = $user->suspend_message;
$attributes['suspendedUntil'] = $serializer->formatDate($user->suspended_until);
}
$attributes['canSuspend'] = $canSuspend;
return $attributes;
}
}

View File

@ -0,0 +1,35 @@
<?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\Suspend\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\User\User;
class UserResourceFields
{
public function __invoke(): array
{
return [
Schema\Boolean::make('canSuspend')
->get($canSuspend = fn (User $user, Context $context) => $context->getActor()->can('suspend', $user)),
Schema\Str::make('suspendReason')
->writable($canSuspend)
->visible($canSuspend),
Schema\Str::make('suspendMessage')
->writable($canSuspend)
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $canSuspend($user, $context)),
Schema\Date::make('suspendedUntil')
->writable($canSuspend)
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $canSuspend($user, $context))
->nullable(),
];
}
}

View File

@ -1,60 +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\Suspend\Listener;
use Carbon\Carbon;
use DateTime;
use Flarum\Suspend\Event\Suspended;
use Flarum\Suspend\Event\Unsuspended;
use Flarum\Suspend\SuspendValidator;
use Flarum\User\Event\Saving;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
class SaveSuspensionToDatabase
{
public function __construct(
protected SuspendValidator $validator,
protected Dispatcher $events
) {
}
public function handle(Saving $event): void
{
$attributes = Arr::get($event->data, 'attributes', []);
if (array_key_exists('suspendedUntil', $attributes)) {
$this->validator->assertValid($attributes);
$user = $event->user;
$actor = $event->actor;
$actor->assertCan('suspend', $user);
if ($attributes['suspendedUntil']) {
$user->suspended_until = Carbon::createFromTimestamp((new DateTime($attributes['suspendedUntil']))->getTimestamp());
$user->suspend_reason = empty($attributes['suspendReason']) ? null : $attributes['suspendReason'];
$user->suspend_message = empty($attributes['suspendMessage']) ? null : $attributes['suspendMessage'];
} else {
$user->suspended_until = null;
$user->suspend_reason = null;
$user->suspend_message = null;
}
if ($user->isDirty(['suspended_until', 'suspend_reason', 'suspend_message'])) {
$this->events->dispatch(
$user->suspended_until === null ?
new Unsuspended($user, $actor) :
new Suspended($user, $actor)
);
}
}
}
}

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Suspend\Listener;
use Flarum\Suspend\Event\Suspended;
use Flarum\Suspend\Event\Unsuspended;
use Flarum\User\Event\Saving;
use Illuminate\Contracts\Events\Dispatcher;
class SavingUser
{
public function __construct(
protected Dispatcher $events
) {
}
public function handle(Saving $event): void
{
$user = $event->user;
$actor = $event->actor;
if ($user->isDirty(['suspended_until', 'suspend_reason', 'suspend_message'])) {
$this->events->dispatch(
$user->suspended_until === null ?
new Unsuspended($user, $actor) :
new Suspended($user, $actor)
);
}
}
}

View File

@ -1,19 +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\Suspend;
use Flarum\Foundation\AbstractValidator;
class SuspendValidator extends AbstractValidator
{
protected array $rules = [
'suspendedUntil' => ['nullable', 'date'],
];
}

View File

@ -48,6 +48,7 @@ class UseForumTest extends TestCase
'authenticatedAs' => 2,
'json' => [
'data' => [
'type' => 'discussions',
'attributes' => [
'title' => 'Test post',
'content' => '<t><p>Hello, world!</p></t>'
@ -68,6 +69,7 @@ class UseForumTest extends TestCase
'authenticatedAs' => 2,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => '<t><p>Hello, world!</p></t>'
],

View File

@ -93,6 +93,7 @@ class SuspendUserTest extends TestCase
'authenticatedAs' => $authenticatedAs,
'json' => [
'data' => [
'type' => 'users',
'attributes' => [
'suspendedUntil' => Carbon::now()->addDay(),
'suspendReason' => 'Suspended for acme reasons.',

View File

@ -7,26 +7,22 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Controller as FlarumController;
use Flarum\Api\Serializer\BasicPostSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend;
use Flarum\Flags\Api\Controller\ListFlagsController;
use Flarum\Http\RequestUtil;
use Flarum\Flags\Api\Resource\FlagResource;
use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\Tags\Access;
use Flarum\Tags\Api\Controller;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Api;
use Flarum\Tags\Content;
use Flarum\Tags\Event\DiscussionWasTagged;
use Flarum\Tags\Listener;
use Flarum\Tags\LoadForumTagsRelationship;
use Flarum\Tags\Post\DiscussionTaggedPost;
use Flarum\Tags\Search\Filter\PostTagFilter;
use Flarum\Tags\Search\Filter\TagFilter;
@ -37,13 +33,6 @@ use Flarum\Tags\Tag;
use Flarum\Tags\Utf8SlugDriver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Psr\Http\Message\ServerRequestInterface;
$eagerLoadTagState = function ($query, ?ServerRequestInterface $request, array $relations) {
if ($request && in_array('tags.state', $relations, true)) {
$query->withStateFor(RequestUtil::getActor($request));
}
};
return [
(new Extend\Frontend('forum'))
@ -61,49 +50,71 @@ return [
->css(__DIR__.'/less/admin.less'),
(new Extend\Routes('api'))
->get('/tags', 'tags.index', Controller\ListTagsController::class)
->post('/tags', 'tags.create', Controller\CreateTagController::class)
->post('/tags/order', 'tags.order', Controller\OrderTagsController::class)
->get('/tags/{slug}', 'tags.show', Controller\ShowTagController::class)
->patch('/tags/{id}', 'tags.update', Controller\UpdateTagController::class)
->delete('/tags/{id}', 'tags.delete', Controller\DeleteTagController::class),
->post('/tags/order', 'tags.order', Api\Controller\OrderTagsController::class),
(new Extend\Model(Discussion::class))
->belongsToMany('tags', Tag::class, 'discussion_tag'),
(new Extend\ApiSerializer(ForumSerializer::class))
->hasMany('tags', TagSerializer::class)
->attribute('canBypassTagCounts', function (ForumSerializer $serializer) {
return $serializer->getActor()->can('bypassTagCounts');
new Extend\ApiResource(Api\Resource\TagResource::class),
(new Extend\ApiResource(Resource\ForumResource::class))
->fields(fn () => [
Schema\Relationship\ToMany::make('tags')
->includable()
->get(function ($model, Context $context) {
$actor = $context->getActor();
return Tag::query()
->where(function ($query) {
$query
->whereNull('parent_id')
->whereNotNull('position');
})
->union(
Tag::whereVisibleTo($actor)
->whereNull('parent_id')
->whereNull('position')
->orderBy('discussion_count', 'desc')
->limit(4) // We get one more than we need so the "more" link can be shown.
)
->whereVisibleTo($actor)
->withStateFor($actor)
->get()
->all();
}),
Schema\Boolean::make('canBypassTagCounts')
->get(fn ($model, Context $context) => $context->getActor()->can('bypassTagCounts')),
])
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) {
return $endpoint->addDefaultInclude(['tags', 'tags.parent']);
}),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->hasMany('tags', TagSerializer::class)
->attribute('canTag', function (DiscussionSerializer $serializer, $model) {
return $serializer->getActor()->can('tag', $model);
(new Extend\ApiResource(Resource\PostResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) {
return $endpoint->eagerLoadWhenIncluded(['discussion' => ['discussion.tags']]);
}),
(new Extend\ApiController(FlarumController\ListPostsController::class))
->load('discussion.tags'),
(new Extend\Conditional())
->whenExtensionEnabled('flarum-flags', fn () => [
(new Extend\ApiResource(FlagResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) {
return $endpoint->eagerLoadWhenIncluded(['post.discussion' => ['post.discussion.tags']]);
}),
]),
(new Extend\ApiController(ListFlagsController::class))
->load('post.discussion.tags'),
(new Extend\ApiController(FlarumController\ListDiscussionsController::class))
->addInclude(['tags', 'tags.state', 'tags.parent'])
->loadWhere('tags', $eagerLoadTagState),
(new Extend\ApiController(FlarumController\ShowDiscussionController::class))
->addInclude(['tags', 'tags.state', 'tags.parent'])
->loadWhere('tags', $eagerLoadTagState),
(new Extend\ApiController(FlarumController\CreateDiscussionController::class))
->addInclude(['tags', 'tags.state', 'tags.parent'])
->loadWhere('tags', $eagerLoadTagState),
(new Extend\ApiController(FlarumController\ShowForumController::class))
->addInclude(['tags', 'tags.parent'])
->prepareDataForSerialization(LoadForumTagsRelationship::class),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(Api\DiscussionResourceFields::class)
->endpoint(
[Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class],
function (Endpoint\Index|Endpoint\Show|Endpoint\Create $endpoint) {
return $endpoint
->addDefaultInclude(['tags', 'tags.parent'])
->eagerLoadWhere('tags', function (Builder|Relation $query, Context $context) {
/** @var Builder<Tag>|Relation $query */
$query->withStateFor($context->getActor());
});
}
),
(new Extend\Settings())
->serializeToForum('minPrimaryTags', 'flarum-tags.min_primary_tags')
@ -131,7 +142,6 @@ return [
->type(DiscussionTaggedPost::class),
(new Extend\Event())
->listen(Saving::class, Listener\SaveTagsToDatabase::class)
->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class)
->subscribe(Listener\UpdateTagMetadata::class),
@ -158,27 +168,20 @@ return [
return $model->mentionsTags();
}),
(new Extend\ApiSerializer(BasicPostSerializer::class))
->relationship('eventPostMentionsTags', function (BasicPostSerializer $serializer, Post $model) {
if ($model instanceof DiscussionTaggedPost) {
return $serializer->hasMany($model, TagSerializer::class, 'eventPostMentionsTags');
}
return null;
})
->hasMany('eventPostMentionsTags', TagSerializer::class),
(new Extend\ApiController(FlarumController\ListPostsController::class))
->addInclude('eventPostMentionsTags')
// Restricted tags should still appear as `deleted` to unauthorized users.
->loadWhere('eventPostMentionsTags', $restrictMentionedTags = function (Relation|Builder $query, ?ServerRequestInterface $request) {
if ($request) {
$actor = RequestUtil::getActor($request);
$query->whereVisibleTo($actor);
}
(new Extend\ApiResource(Resource\PostResource::class))
->fields(fn () => [
Schema\Relationship\ToMany::make('eventPostMentionsTags')
->type('tags')
->includable(),
])
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) {
return $endpoint
->addDefaultInclude(['eventPostMentionsTags']);
}),
(new Extend\ApiController(FlarumController\ShowDiscussionController::class))
->addInclude('posts.eventPostMentionsTags')
->loadWhere('posts.eventPostMentionsTags', $restrictMentionedTags),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) {
return $endpoint
->addDefaultInclude(['posts.eventPostMentionsTags']);
}),
];

View File

@ -30,7 +30,7 @@ export default class EditTagModal extends FormModal<EditTagModalAttrs> {
color!: Stream<string>;
icon!: Stream<string>;
isHidden!: Stream<boolean>;
primary!: Stream<boolean>;
isPrimary!: Stream<boolean>;
oninit(vnode: Mithril.Vnode<EditTagModalAttrs, this>) {
super.oninit(vnode);
@ -43,7 +43,7 @@ export default class EditTagModal extends FormModal<EditTagModalAttrs> {
this.color = Stream(this.tag.color() || '');
this.icon = Stream(this.tag.icon() || '');
this.isHidden = Stream(this.tag.isHidden() || false);
this.primary = Stream(this.attrs.primary || false);
this.isPrimary = Stream(this.attrs.primary || false);
}
className() {
@ -164,7 +164,7 @@ export default class EditTagModal extends FormModal<EditTagModalAttrs> {
color: this.color(),
icon: this.icon(),
isHidden: this.isHidden(),
primary: this.primary(),
isPrimary: this.isPrimary(),
};
}
@ -189,8 +189,6 @@ export default class EditTagModal extends FormModal<EditTagModalAttrs> {
children.forEach((tag) =>
tag.pushData({
attributes: { isChild: false },
// @deprecated. Temporary hack for type safety, remove before v1.3.
relationships: { parent: null as any as [] },
})
);
m.redraw();

View File

@ -4,12 +4,12 @@ import Button from 'flarum/common/components/Button';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import tagsLabel from '../../common/helpers/tagsLabel';
import type { CommonSettingsItemOptions } from 'flarum/admin/components/AdminPage';
import type { CommonFieldOptions } from 'flarum/common/components/FormGroup';
import type Stream from 'flarum/common/utils/Stream';
import type { ITagSelectionModalAttrs } from '../../common/components/TagSelectionModal';
import type Tag from '../../common/models/Tag';
export interface SelectTagsSettingComponentOptions extends CommonSettingsItemOptions {
export interface SelectTagsSettingComponentOptions extends CommonFieldOptions {
type: 'flarum-tags.select-tags';
options?: ITagSelectionModalAttrs;
}

View File

@ -252,10 +252,10 @@ export default class TagSelectionModal<
// we'll filter out all other tags of that type.
else {
if (primaryCount >= this.attrs.limits!.max!.primary!) {
tags = tags.filter((tag) => !tag.isPrimary() || this.selected.includes(tag));
tags = tags.filter((tag) => !tag.isPrimaryParent() || this.selected.includes(tag));
}
if (secondaryCount >= this.attrs.limits!.max!.secondary!) {
tags = tags.filter((tag) => tag.isPrimary() || this.selected.includes(tag));
tags = tags.filter((tag) => tag.isPrimaryParent() || this.selected.includes(tag));
}
}
}
@ -275,14 +275,14 @@ export default class TagSelectionModal<
* Counts the number of selected primary tags.
*/
protected primaryCount(): number {
return this.selected.filter((tag) => tag.isPrimary()).length;
return this.selected.filter((tag) => tag.isPrimaryParent()).length;
}
/**
* Counts the number of selected secondary tags.
*/
protected secondaryCount(): number {
return this.selected.filter((tag) => !tag.isPrimary()).length;
return this.selected.filter((tag) => !tag.isPrimaryParent()).length;
}
/**

View File

@ -44,6 +44,9 @@ export default class Tag extends Model {
isHidden() {
return Model.attribute<boolean>('isHidden').call(this);
}
isPrimary() {
return Model.attribute<boolean>('isPrimary').call(this);
}
discussionCount() {
return Model.attribute<number>('discussionCount').call(this);
@ -65,7 +68,7 @@ export default class Tag extends Model {
return Model.attribute<boolean>('canAddToDiscussion').call(this);
}
isPrimary() {
isPrimaryParent() {
return computed<boolean, this>('position', 'parent', (position, parent) => position !== null && parent === false).call(this);
}
}

View File

@ -39,7 +39,7 @@ export default function addTagFilter() {
// - We loaded in that child tag (and its siblings) in the API document
// - We first navigated to the current tag's parent, which would have loaded in the current tag's siblings.
this.store
.find('tags', slug, { include: 'children,children.parent,parent,state' })
.find('tags', slug, { include: 'children,children.parent,parent' })
.then(() => {
this.currentActiveTag = findTag(slug);

View File

@ -0,0 +1,29 @@
<?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.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->boolean('is_primary')->default(false)->after('background_mode');
});
$schema->getConnection()
->table('tags')
->whereNotNull('position')
->update(['is_primary' => true]);
},
'down' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->dropColumn('is_primary');
});
}
];

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\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractCreateController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Command\CreateTag;
use Flarum\Tags\Tag;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateTagController extends AbstractCreateController
{
public ?string $serializer = TagSerializer::class;
public array $include = ['parent'];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Tag
{
return $this->bus->dispatch(
new CreateTag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
);
}
}

View File

@ -1,32 +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\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractDeleteController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Command\DeleteTag;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteTagController extends AbstractDeleteController
{
public function __construct(
protected Dispatcher $bus
) {
}
protected function delete(ServerRequestInterface $request): void
{
$this->bus->dispatch(
new DeleteTag(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
);
}
}

View File

@ -1,78 +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\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractListController;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Tag;
use Flarum\Tags\TagRepository;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListTagsController extends AbstractListController
{
public ?string $serializer = TagSerializer::class;
public array $include = [
'parent'
];
public array $optionalInclude = [
'children',
'lastPostedDiscussion',
'state'
];
public function __construct(
protected TagRepository $tags,
protected SearchManager $search,
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$include = $this->extractInclude($request);
$filters = $this->extractFilter($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
if (in_array('lastPostedDiscussion', $include)) {
$include = array_merge($include, ['lastPostedDiscussion.tags', 'lastPostedDiscussion.state']);
}
if (array_key_exists('q', $filters)) {
$results = $this->search->query(Tag::class, new SearchCriteria($actor, $filters, $limit, $offset));
$tags = $results->getResults();
$document->addPaginationLinks(
$this->url->to('api')->route('tags.index'),
$request->getQueryParams(),
$offset,
$limit,
$results->areMoreResults() ? null : 0
);
} else {
$tags = $this->tags
->with($include, $actor)
->whereVisibleTo($actor)
->withStateFor($actor)
->get();
}
return $tags;
}
}

View File

@ -31,19 +31,21 @@ class OrderTagsController implements RequestHandlerInterface
Tag::query()->update([
'position' => null,
'parent_id' => null
'parent_id' => null,
'is_primary' => false,
]);
foreach ($order as $i => $parent) {
$parentId = Arr::get($parent, 'id');
Tag::where('id', $parentId)->update(['position' => $i]);
Tag::where('id', $parentId)->update(['position' => $i, 'is_primary' => true]);
if (isset($parent['children']) && is_array($parent['children'])) {
foreach ($parent['children'] as $j => $childId) {
Tag::where('id', $childId)->update([
'position' => $j,
'parent_id' => $parentId
'parent_id' => $parentId,
'is_primary' => true,
]);
}
}

View File

@ -1,69 +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\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractShowController;
use Flarum\Http\RequestUtil;
use Flarum\Http\SlugManager;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Tag;
use Flarum\Tags\TagRepository;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowTagController extends AbstractShowController
{
public ?string $serializer = TagSerializer::class;
public array $optionalInclude = [
'children',
'children.parent',
'lastPostedDiscussion',
'parent',
'parent.children',
'parent.children.parent',
'state'
];
public function __construct(
protected TagRepository $tags,
protected SlugManager $slugger
) {
}
protected function data(ServerRequestInterface $request, Document $document): Tag
{
$slug = Arr::get($request->getQueryParams(), 'slug');
$actor = RequestUtil::getActor($request);
$include = $this->extractInclude($request);
$setParentOnChildren = false;
if (in_array('parent.children.parent', $include, true)) {
$setParentOnChildren = true;
$include[] = 'parent.children';
$include = array_unique(array_diff($include, ['parent.children.parent']));
}
$tag = $this->slugger
->forResource(Tag::class)
->fromSlug($slug, $actor);
$tag->load($this->tags->getAuthorizedRelations($include, $actor));
if ($setParentOnChildren && $tag->parent) {
foreach ($tag->parent->children as $child) {
$child->parent = $tag->parent;
}
}
return $tag;
}
}

View File

@ -1,41 +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\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractShowController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Command\EditTag;
use Flarum\Tags\Tag;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdateTagController extends AbstractShowController
{
public ?string $serializer = TagSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Tag
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data', []);
return $this->bus->dispatch(
new EditTag($id, $actor, $data)
);
}
}

View File

@ -0,0 +1,114 @@
<?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\Tags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Tags\Event\DiscussionWasTagged;
use Flarum\Tags\Tag;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Validation\Factory;
class DiscussionResourceFields
{
public function __construct(
protected SettingsRepositoryInterface $settings,
protected Factory $validator,
protected TranslatorInterface $translator
) {
}
public function __invoke(): array
{
return [
Schema\Boolean::make('canTag')
->get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('tag', $discussion)),
Schema\Relationship\ToMany::make('tags')
->includable()
->writable()
->required(fn (Context $context, Discussion $discussion) => ! $context->getActor()->can('bypassTagCounts', $discussion))
->set(function (Discussion $discussion, array $newTags, Context $context) {
$actor = $context->getActor();
$newTagIds = array_map(fn (Tag $tag) => $tag->id, $newTags);
$primaryParentCount = 0;
$secondaryOrPrimaryChildCount = 0;
if ($discussion->exists) {
$actor->assertCan('tag', $discussion);
$oldTags = $discussion->tags()->get();
$oldTagIds = $oldTags->pluck('id')->all();
if ($oldTagIds == $newTagIds) {
return;
}
foreach ($newTags as $tag) {
if (! in_array($tag->id, $oldTagIds) && $actor->cannot('addToDiscussion', $tag)) {
throw new PermissionDeniedException;
}
}
$discussion->raise(
new DiscussionWasTagged($discussion, $actor, $oldTags->all())
);
}
foreach ($newTags as $tag) {
if (! $discussion->exists && $actor->cannot('startDiscussion', $tag)) {
throw new PermissionDeniedException;
}
if ($tag->position !== null && $tag->parent_id === null) {
$primaryParentCount++;
} else {
$secondaryOrPrimaryChildCount++;
}
}
if (! $discussion->exists && $primaryParentCount === 0 && $secondaryOrPrimaryChildCount === 0 && ! $actor->hasPermission('startDiscussion')) {
throw new PermissionDeniedException;
}
if (! $actor->can('bypassTagCounts', $discussion)) {
$this->validateTagCount('primary', $primaryParentCount);
$this->validateTagCount('secondary', $secondaryOrPrimaryChildCount);
}
$discussion->afterSave(function ($discussion) use ($newTagIds) {
$discussion->tags()->sync($newTagIds);
$discussion->unsetRelation('tags');
});
}),
];
}
protected function validateTagCount(string $type, int $count): void
{
$min = $this->settings->get('flarum-tags.min_'.$type.'_tags');
$max = $this->settings->get('flarum-tags.max_'.$type.'_tags');
$key = 'tag_count_'.$type;
$validator = $this->validator->make(
[$key => $count],
[$key => ['numeric', $min === $max ? "size:$min" : "between:$min,$max"]]
);
if ($validator->fails()) {
throw new ValidationException([], ['tags' => $validator->getMessageBag()->first($key)]);
}
}
}

View File

@ -0,0 +1,168 @@
<?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\Tags\Api\Resource;
use Flarum\Api\Context as FlarumContext;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Schema;
use Flarum\Http\SlugManager;
use Flarum\Tags\Event\Creating;
use Flarum\Tags\Event\Deleting;
use Flarum\Tags\Event\Saving;
use Flarum\Tags\Tag;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Tobyz\JsonApiServer\Context;
/**
* @extends AbstractDatabaseResource<Tag>
*/
class TagResource extends AbstractDatabaseResource
{
public function __construct(
protected SlugManager $slugManager
) {
}
public function type(): string
{
return 'tags';
}
public function model(): string
{
return Tag::class;
}
public function scope(Builder $query, Context $context): void
{
$query->whereVisibleTo($context->getActor());
if ($context->listing(self::class) || $context->showing(self::class)) {
$query->withStateFor($context->getActor());
}
}
public function find(string $id, Context $context): ?object
{
$actor = $context->getActor();
if (is_numeric($id) && $tag = $this->query($context)->find($id)) {
return $tag;
}
return $this->slugManager->forResource(Tag::class)->fromSlug($id, $actor);
}
public function endpoints(): array
{
return [
Endpoint\Show::make(),
Endpoint\Create::make()
->authenticated()
->can('createTag'),
Endpoint\Update::make()
->authenticated()
->can('edit'),
Endpoint\Delete::make()
->authenticated()
->can('delete'),
Endpoint\Index::make()
->defaultInclude(['parent']),
];
}
public function fields(): array
{
return [
Schema\Str::make('name')
->requiredOnCreate()
->writable(),
Schema\Str::make('description')
->writable()
->maxLength(700)
->nullable(),
Schema\Str::make('slug')
->requiredOnCreate()
->writable()
->unique('tags', 'slug', true)
->regex('/^[^\/\\ ]*$/i')
->get(function (Tag $tag) {
return $this->slugManager->forResource($tag::class)->toSlug($tag);
}),
Schema\Str::make('color')
->writable()
->nullable()
->rule('hex_color'),
Schema\Str::make('icon')
->writable()
->nullable(),
Schema\Boolean::make('isHidden')
->writable(),
Schema\Boolean::make('isPrimary')
->writable(),
Schema\Boolean::make('isRestricted')
->writableOnUpdate()
->visible(fn (Tag $tag, FlarumContext $context) => $context->getActor()->isAdmin()),
Schema\Str::make('backgroundUrl')
->get(fn (Tag $tag) => $tag->background_path),
Schema\Str::make('backgroundMode'),
Schema\Integer::make('discussionCount'),
Schema\Integer::make('position')
->nullable(),
Schema\Str::make('defaultSort')
->nullable(),
Schema\Boolean::make('isChild')
->get(fn (Tag $tag) => (bool) $tag->parent_id),
Schema\DateTime::make('lastPostedAt'),
Schema\Boolean::make('canStartDiscussion')
->get(fn (Tag $tag, FlarumContext $context) => $context->getActor()->can('startDiscussion', $tag)),
Schema\Boolean::make('canAddToDiscussion')
->get(fn (Tag $tag, FlarumContext $context) => $context->getActor()->can('addToDiscussion', $tag)),
Schema\Relationship\ToOne::make('parent')
->type('tags')
->includable()
->writable(fn (Tag $tag, FlarumContext $context) => (bool) Arr::get($context->body(), 'attributes.isPrimary')),
Schema\Relationship\ToMany::make('children')
->type('tags')
->includable(),
Schema\Relationship\ToOne::make('lastPostedDiscussion')
->type('discussions')
->includable(),
];
}
public function creating(object $model, Context $context): ?object
{
$this->events->dispatch(
new Creating($model, $context->getActor(), $context->body())
);
return $model;
}
public function saving(object $model, Context $context): ?object
{
if (! $context->creating(self::class)) {
$this->events->dispatch(
new Saving($model, $context->getActor(), $context->body())
);
}
return $model;
}
public function deleting(object $model, Context $context): void
{
$this->events->dispatch(new Deleting($model, $context->getActor()));
}
}

View File

@ -1,75 +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\Tags\Api\Serializer;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Http\SlugManager;
use Flarum\Tags\Tag;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class TagSerializer extends AbstractSerializer
{
protected $type = 'tags';
public function __construct(
protected SlugManager $slugManager
) {
}
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Tag)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.Tag::class
);
}
$attributes = [
'name' => $model->name,
'description' => $model->description,
'slug' => $this->slugManager->forResource(Tag::class)->toSlug($model),
'color' => $model->color,
'backgroundUrl' => $model->background_path,
'backgroundMode' => $model->background_mode,
'icon' => $model->icon,
'discussionCount' => (int) $model->discussion_count,
'position' => $model->position === null ? null : (int) $model->position,
'defaultSort' => $model->default_sort,
'isChild' => (bool) $model->parent_id,
'isHidden' => (bool) $model->is_hidden,
'lastPostedAt' => $this->formatDate($model->last_posted_at),
'canStartDiscussion' => $this->actor->can('startDiscussion', $model),
'canAddToDiscussion' => $this->actor->can('addToDiscussion', $model)
];
if ($this->actor->isAdmin()) {
$attributes['isRestricted'] = (bool) $model->is_restricted;
}
return $attributes;
}
protected function parent(Tag $tag): ?Relationship
{
return $this->hasOne($tag, self::class);
}
protected function children(Tag $tag): ?Relationship
{
return $this->hasMany($tag, self::class);
}
protected function lastPostedDiscussion(Tag $tag): ?Relationship
{
return $this->hasOne($tag, DiscussionSerializer::class);
}
}

View File

@ -1,66 +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\Tags\Command;
use Flarum\Tags\Event\Creating;
use Flarum\Tags\Tag;
use Flarum\Tags\TagValidator;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
class CreateTagHandler
{
public function __construct(
protected TagValidator $validator,
protected Dispatcher $events
) {
}
public function handle(CreateTag $command): Tag
{
$actor = $command->actor;
$data = $command->data;
$actor->assertCan('createTag');
$tag = Tag::build(
Arr::get($data, 'attributes.name'),
Arr::get($data, 'attributes.slug'),
Arr::get($data, 'attributes.description'),
Arr::get($data, 'attributes.color'),
Arr::get($data, 'attributes.icon'),
Arr::get($data, 'attributes.isHidden')
);
$parentId = Arr::get($data, 'relationships.parent.data.id');
$primary = Arr::get($data, 'attributes.primary');
if ($parentId !== null || $primary) {
$rootTags = Tag::whereNull('parent_id')->whereNotNull('position');
if ($parentId === 0 || $primary) {
$tag->position = $rootTags->max('position') + 1;
} elseif ($rootTags->find($parentId)) {
$position = Tag::where('parent_id', $parentId)->max('position');
$tag->parent()->associate($parentId);
$tag->position = $position === null ? 0 : $position + 1;
}
}
$this->events->dispatch(new Creating($tag, $actor, $data));
$this->validator->assertValid($tag->getAttributes());
$tag->save();
return $tag;
}
}

View File

@ -1,22 +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\Tags\Command;
use Flarum\User\User;
class DeleteTag
{
public function __construct(
public int $tagId,
public User $actor,
public array $data = []
) {
}
}

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\Tags\Command;
use Flarum\Tags\Event\Deleting;
use Flarum\Tags\Tag;
use Flarum\Tags\TagRepository;
use Illuminate\Contracts\Events\Dispatcher;
class DeleteTagHandler
{
public function __construct(
protected TagRepository $tags,
protected Dispatcher $events
) {
}
public function handle(DeleteTag $command): Tag
{
$actor = $command->actor;
$tag = $this->tags->findOrFail($command->tagId, $actor);
$actor->assertCan('delete', $tag);
$this->events->dispatch(new Deleting($tag, $actor));
$tag->delete();
return $tag;
}
}

View File

@ -1,22 +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\Tags\Command;
use Flarum\User\User;
class EditTag
{
public function __construct(
public int $tagId,
public User $actor,
public array $data
) {
}
}

View File

@ -1,75 +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\Tags\Command;
use Flarum\Tags\Event\Saving;
use Flarum\Tags\Tag;
use Flarum\Tags\TagRepository;
use Flarum\Tags\TagValidator;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
class EditTagHandler
{
public function __construct(
protected TagRepository $tags,
protected TagValidator $validator,
protected Dispatcher $events
) {
}
public function handle(EditTag $command): Tag
{
$actor = $command->actor;
$data = $command->data;
$tag = $this->tags->findOrFail($command->tagId, $actor);
$actor->assertCan('edit', $tag);
$attributes = Arr::get($data, 'attributes', []);
if (isset($attributes['name'])) {
$tag->name = $attributes['name'];
}
if (isset($attributes['slug'])) {
$tag->slug = $attributes['slug'];
}
if (isset($attributes['description'])) {
$tag->description = $attributes['description'];
}
if (isset($attributes['color'])) {
$tag->color = $attributes['color'];
}
if (isset($attributes['icon'])) {
$tag->icon = $attributes['icon'];
}
if (isset($attributes['isHidden'])) {
$tag->is_hidden = (bool) $attributes['isHidden'];
}
if (isset($attributes['isRestricted'])) {
$tag->is_restricted = (bool) $attributes['isRestricted'];
}
$this->events->dispatch(new Saving($tag, $actor, $data));
$this->validator->assertValid($tag->getDirty());
$tag->save();
return $tag;
}
}

View File

@ -10,6 +10,7 @@
namespace Flarum\Tags\Content;
use Flarum\Api\Client;
use Flarum\Api\Resource\DiscussionResource;
use Flarum\Frontend\Document;
use Flarum\Http\RequestUtil;
use Flarum\Http\SlugManager;
@ -27,7 +28,8 @@ class Tag
protected Factory $view,
protected TagRepository $tags,
protected TranslatorInterface $translator,
protected SlugManager $slugger
protected SlugManager $slugger,
protected DiscussionResource $resource
) {
}
@ -42,7 +44,7 @@ class Tag
$page = Arr::pull($queryParams, 'page', 1);
$filters = Arr::pull($queryParams, 'filter', []);
$sortMap = $this->getSortMap();
$sortMap = $this->resource->sortMap();
$tag = $this->slugger->forResource(TagModel::class)->fromSlug($slug, $actor);
@ -78,14 +80,6 @@ class Tag
return $document;
}
/**
* Get a map of sort query param values and their API sort params.
*/
protected function getSortMap(): array
{
return resolve('flarum.forum.discussions.sortmap');
}
/**
* Get the result of an API request to list discussions.
*/
@ -101,7 +95,7 @@ class Tag
->withoutErrorHandling()
->withParentRequest($request)
->withQueryParams([
'include' => 'children,children.parent,parent,parent.children.parent,state'
'include' => 'children,children.parent,parent,parent.children.parent'
])
->get("/tags/$slug")
->getBody()

View File

@ -1,116 +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\Tags\Listener;
use Flarum\Discussion\Event\Saving;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Tags\Event\DiscussionWasTagged;
use Flarum\Tags\Tag;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Validation\Factory;
class SaveTagsToDatabase
{
public function __construct(
protected SettingsRepositoryInterface $settings,
protected Factory $validator,
protected TranslatorInterface $translator
) {
}
public function handle(Saving $event): void
{
$discussion = $event->discussion;
$actor = $event->actor;
$newTagIds = [];
$newTags = [];
$primaryCount = 0;
$secondaryCount = 0;
if (isset($event->data['relationships']['tags']['data'])) {
$linkage = (array) $event->data['relationships']['tags']['data'];
foreach ($linkage as $link) {
$newTagIds[] = (int) $link['id'];
}
$newTags = Tag::whereIn('id', $newTagIds)->get();
}
if ($discussion->exists && isset($event->data['relationships']['tags']['data'])) {
$actor->assertCan('tag', $discussion);
$oldTags = $discussion->tags()->get();
$oldTagIds = $oldTags->pluck('id')->all();
if ($oldTagIds == $newTagIds) {
return;
}
foreach ($newTags as $tag) {
if (! in_array($tag->id, $oldTagIds) && $actor->cannot('addToDiscussion', $tag)) {
throw new PermissionDeniedException;
}
}
$discussion->raise(
new DiscussionWasTagged($discussion, $actor, $oldTags->all())
);
}
if (! $discussion->exists || isset($event->data['relationships']['tags']['data'])) {
foreach ($newTags as $tag) {
if (! $discussion->exists && $actor->cannot('startDiscussion', $tag)) {
throw new PermissionDeniedException;
}
if ($tag->position !== null && $tag->parent_id === null) {
$primaryCount++;
} else {
$secondaryCount++;
}
}
if (! $discussion->exists && $primaryCount === 0 && $secondaryCount === 0 && ! $actor->hasPermission('startDiscussion')) {
throw new PermissionDeniedException;
}
if (! $actor->can('bypassTagCounts', $discussion)) {
$this->validateTagCount('primary', $primaryCount);
$this->validateTagCount('secondary', $secondaryCount);
}
$discussion->afterSave(function ($discussion) use ($newTagIds) {
$discussion->tags()->sync($newTagIds);
$discussion->unsetRelation('tags');
});
}
}
protected function validateTagCount(string $type, int $count): void
{
$min = $this->settings->get('flarum-tags.min_'.$type.'_tags');
$max = $this->settings->get('flarum-tags.max_'.$type.'_tags');
$key = 'tag_count_'.$type;
$validator = $this->validator->make(
[$key => $count],
[$key => ['numeric', $min === $max ? "size:$min" : "between:$min,$max"]]
);
if ($validator->fails()) {
throw new ValidationException([], ['tags' => $validator->getMessageBag()->first($key)]);
}
}
}

View File

@ -1,43 +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\Tags;
use Flarum\Api\Controller\ShowForumController;
use Flarum\Http\RequestUtil;
use Psr\Http\Message\ServerRequestInterface;
class LoadForumTagsRelationship
{
public function __invoke(ShowForumController $controller, array &$data, ServerRequestInterface $request): void
{
$actor = RequestUtil::getActor($request);
// Expose the complete tag list to clients by adding it as a
// relationship to the /api endpoint. Since the Forum model
// doesn't actually have a tags relationship, we will manually load and
// assign the tags data to it using an event listener.
$data['tags'] = Tag::query()
->where(function ($query) {
$query
->whereNull('parent_id')
->whereNotNull('position');
})
->union(
Tag::whereVisibleTo($actor)
->whereNull('parent_id')
->whereNull('position')
->orderBy('discussion_count', 'desc')
->limit(4) // We get one more than we need so the "more" link can be shown.
)
->whereVisibleTo($actor)
->withStateFor($actor)
->get();
}
}

View File

@ -31,9 +31,10 @@ use Illuminate\Database\Query\Builder as QueryBuilder;
* @property string $color
* @property string $background_path
* @property string $background_mode
* @property bool $is_primary
* @property int $position
* @property int $parent_id
* @property string $default_sort
* @property string|null $default_sort
* @property bool $is_restricted
* @property bool $is_hidden
* @property int $discussion_count
@ -59,6 +60,7 @@ class Tag extends AbstractModel
protected $casts = [
'is_hidden' => 'bool',
'is_restricted' => 'bool',
'is_primary' => 'bool',
'last_posted_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
@ -74,6 +76,15 @@ class Tag extends AbstractModel
}
});
static::creating(function (self $tag) {
if ($tag->is_primary) {
$tag->position = static::query()
->when($tag->parent_id, fn ($query) => $query->where('parent_id', $tag->parent_id))
->where('is_primary', true)
->max('position') + 1;
}
});
static::deleted(function (self $tag) {
$tag->deletePermissions();
});

View File

@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Collection;
class TagRepository
{
private const TAG_RELATIONS = ['children', 'parent', 'parent.children'];
/**
* @return Builder<Tag>
*/
@ -30,32 +28,6 @@ class TagRepository
return $this->scopeVisibleTo($this->query(), $actor);
}
/**
* @return Builder<Tag>
*/
public function with(array|string $relations, User $actor): Builder
{
return $this->query()->with($this->getAuthorizedRelations($relations, $actor));
}
public function getAuthorizedRelations(array|string $relations, User $actor): array
{
$relations = is_string($relations) ? explode(',', $relations) : $relations;
$relationsArray = [];
foreach ($relations as $relation) {
if (in_array($relation, self::TAG_RELATIONS, true)) {
$relationsArray[$relation] = function ($query) use ($actor) {
$query->whereVisibleTo($actor);
};
} else {
$relationsArray[] = $relation;
}
}
return $relationsArray;
}
/**
* Find a tag by ID, optionally making sure it is visible to a certain
* user, or throw an exception.

View File

@ -1,23 +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\Tags;
use Flarum\Foundation\AbstractValidator;
class TagValidator extends AbstractValidator
{
protected array $rules = [
'name' => ['required'],
'slug' => ['required', 'unique:tags', 'regex:/^[^\/\\ ]*$/i'],
'is_hidden' => ['bool'],
'description' => ['string', 'max:700'],
'color' => ['hex_color'],
];
}

Some files were not shown because too many files have changed in this diff Show More