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

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
296 changed files with 7148 additions and 8860 deletions

View File

@@ -79,7 +79,6 @@
"psr/http-server-middleware": "^1.0.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",
@@ -92,6 +91,7 @@
"symfony/translation": "^6.3",
"symfony/translation-contracts": "^2.5",
"symfony/yaml": "^6.3",
"flarum/json-api-server": "^1.0.0",
"wikimedia/less.php": "^4.1"
},
"require-dev": {

View File

@@ -207,11 +207,9 @@ export default class CreateUserModal<CustomAttrs extends ICreateUserModalAttrs =
this.loading = true;
app
.request({
url: app.forum.attribute('apiUrl') + '/users',
method: 'POST',
body: { data: { attributes: this.submitData() } },
app.store
.createRecord('users', {})
.save(this.submitData(), {
errorHandler: this.onerror.bind(this),
})
.then(() => {

View File

@@ -121,6 +121,10 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
include,
};
if (typeof params.include === 'undefined') {
delete params.include;
}
return app.store.find<T[]>(this.type, params).then((results) => {
/*
* If this state does not rely on a preloaded API document to know the page size,

View File

@@ -7,7 +7,7 @@ test('gambits are converted to filters', function () {
q: 'lorem',
created: '2023-07-07',
hidden: true,
author: ['behz'],
author: 'behz',
});
});
@@ -16,7 +16,7 @@ test('gambits are negated when prefixed with a dash', function () {
q: 'lorem',
'-created': '2023-07-07',
'-hidden': true,
'-author': ['behz'],
'-author': 'behz',
});
});
@@ -29,6 +29,6 @@ test('gambits are only applied for the correct resource type', function () {
q: 'lorem email:behz@machine.local',
created: '2023-07-07..2023-10-18',
hidden: true,
'-author': ['behz'],
'-author': 'behz',
});
});

View File

@@ -1,107 +1,139 @@
validation:
accepted: "The :attribute must be accepted."
active_url: "The :attribute is not a valid URL."
after: "The :attribute must be a date after :date."
after_or_equal: "The :attribute must be a date after or equal to :date."
alpha: "The :attribute must only contain letters."
alpha_dash: "The :attribute must only contain letters, numbers, dashes and underscores."
alpha_num: "The :attribute must only contain letters and numbers."
array: "The :attribute must be an array."
before: "The :attribute must be a date before :date."
before_or_equal: "The :attribute must be a date before or equal to :date."
accepted: "The :attribute field must be accepted."
accepted_if: "The :attribute field must be accepted when :other is :value."
active_url: "The :attribute field must be a valid URL."
after: "The :attribute field must be a date after :date."
after_or_equal: "The :attribute field must be a date after or equal to :date."
alpha: "The :attribute field must only contain letters."
alpha_dash: "The :attribute field must only contain letters, numbers, dashes, and underscores."
alpha_num: "The :attribute field must only contain letters and numbers."
array: "The :attribute field must be an array."
ascii: "The :attribute field must only contain single-byte alphanumeric characters and symbols."
before: "The :attribute field must be a date before :date."
before_or_equal: "The :attribute field must be a date before or equal to :date."
between:
numeric: "The :attribute must be between :min and :max."
file: "The :attribute must be between :min and :max kilobytes."
string: "The :attribute must be between :min and :max characters."
array: "The :attribute must have between :min and :max items."
array: "The :attribute field must contain between :min and :max items."
file: "The :attribute field must be between :min and :max kilobytes."
numeric: "The :attribute field must be between :min and :max."
string: "The :attribute field must be between :min and :max characters."
boolean: "The :attribute field must be true or false."
confirmed: "The :attribute confirmation does not match."
date: "The :attribute is not a valid date."
date_equals: "The :attribute must be a date equal to :date."
date_format: "The :attribute does not match the format :format."
different: "The :attribute and :other must be different."
digits: "The :attribute must be :digits digits."
digits_between: "The :attribute must be between :min and :max digits."
dimensions: "The :attribute has invalid image dimensions."
can: "The :attribute field contains an unauthorized value."
confirmed: "The :attribute field confirmation does not match."
current_password: "The password is incorrect."
date: "The :attribute field must be a valid date."
date_equals: "The :attribute field must be a date equal to :date."
date_format: "The :attribute field must match the format :format."
decimal: "The :attribute field must have :decimal decimal places."
declined: "The :attribute field must be declined."
declined_if: "The :attribute field must be declined when :other is :value."
different: "The :attribute field and :other must be different."
digits: "The :attribute field must be :digits digits."
digits_between: "The :attribute field must be between :min and :max digits."
dimensions: "The :attribute field has invalid image dimensions."
distinct: "The :attribute field has a duplicate value."
email: "The :attribute must be a valid email address."
ends_with: "The :attribute must end with one of the following: :values."
doesnt_end_with: "The :attribute field must not end with one of the following: :values."
doesnt_start_with: "The :attribute field must not start with one of the following: :values."
email: "The :attribute field must be a valid email address."
ends_with: "The :attribute field must end with one of the following: :values."
enum: "The selected :attribute is invalid."
exists: "The selected :attribute is invalid."
file: "The :attribute must be a file."
file_too_large: "The :attribute is too large."
file_upload_failed: "The :attribute failed to upload."
extensions: "The :attribute field must have one of the following extensions: :values."
file: "The :attribute field must be a file."
filled: "The :attribute field must have a value."
gt:
numeric: "The :attribute must be greater than :value."
file: "The :attribute must be greater than :value kilobytes."
string: "The :attribute must be greater than :value characters."
array: "The :attribute must have more than :value items."
array: "The :attribute field must have more than :value items."
file: "The :attribute field must be greater than :value kilobytes."
numeric: "The :attribute field must be greater than :value."
string: "The :attribute field must be greater than :value characters."
gte:
numeric: "The :attribute must be greater than or equal :value."
file: "The :attribute must be greater than or equal :value kilobytes."
string: "The :attribute must be greater than or equal :value characters."
array: "The :attribute must have :value items or more."
array: "The :attribute field must have :value items or more."
file: "The :attribute field must be greater than or equal to :value kilobytes."
numeric: "The :attribute field must be greater than or equal to :value."
string: "The :attribute field must be greater than or equal to :value characters."
hex_color: "The :attribute field must be a valid hexadecimal color."
image: "The :attribute must be an image."
image: "The :attribute field must be an image."
in: "The selected :attribute is invalid."
in_array: "The :attribute field does not exist in :other."
integer: "The :attribute must be an integer."
ip: "The :attribute must be a valid IP address."
ipv4: "The :attribute must be a valid IPv4 address."
ipv6: "The :attribute must be a valid IPv6 address."
json: "The :attribute must be a valid JSON string."
in_array: "The :attribute field must exist in :other."
integer: "The :attribute field must be an integer."
ip: "The :attribute field must be a valid IP address."
ipv4: "The :attribute field must be a valid IPv4 address."
ipv6: "The :attribute field must be a valid IPv6 address."
json: "The :attribute field must be a valid JSON string."
lowercase: "The :attribute field must be lowercase."
lt:
numeric: "The :attribute must be less than :value."
file: "The :attribute must be less than :value kilobytes."
string: "The :attribute must be less than :value characters."
array: "The :attribute must have less than :value items."
array: "The :attribute field must have less than :value items."
file: "The :attribute field must be less than :value kilobytes."
numeric: "The :attribute field must be less than :value."
string: "The :attribute field must be less than :value characters."
lte:
numeric: "The :attribute must be less than or equal :value."
file: "The :attribute must be less than or equal :value kilobytes."
string: "The :attribute must be less than or equal :value characters."
array: "The :attribute must not have more than :value items."
array: "The :attribute field must not have more than :value items."
file: "The :attribute field must be less than or equal to :value kilobytes."
numeric: "The :attribute field must be less than or equal to :value."
string: "The :attribute field must be less than or equal to :value characters."
mac_address: "The :attribute field must be a valid MAC address."
max:
numeric: "The :attribute must not be greater than :max."
file: "The :attribute must not be greater than :max kilobytes."
string: "The :attribute must not be greater than :max characters."
array: "The :attribute must not have more than :max items."
mimes: "The :attribute must be a file of type: :values."
mimetypes: "The :attribute must be a file of type: :values."
array: "The :attribute field must not have more than :max items."
file: "The :attribute field must not be greater than :max kilobytes."
numeric: "The :attribute field must not be greater than :max."
string: "The :attribute field must not be greater than :max characters."
max_digits: "The :attribute field must not have more than :max digits."
mimes: "The :attribute field must be a file of type: :values."
mimetypes: "The :attribute field must be a file of type: :values."
min:
numeric: "The :attribute must be at least :min."
file: "The :attribute must be at least :min kilobytes."
string: "The :attribute must be at least :min characters."
array: "The :attribute must have at least :min items."
multiple_of: "The :attribute must be a multiple of :value."
array: "The :attribute field must have at least :min items."
file: "The :attribute field must be at least :min kilobytes."
numeric: "The :attribute field must be at least :min."
string: "The :attribute field must be at least :min characters."
min_digits: "The :attribute field must have at least :min digits."
missing: "The :attribute field must be missing."
missing_if: "The :attribute field must be missing when :other is :value."
missing_unless: "The :attribute field must be missing unless :other is :value."
missing_with: "The :attribute field must be missing when :values is present."
missing_with_all: "The :attribute field must be missing when :values are present."
multiple_of: "The :attribute field must be a multiple of :value."
not_in: "The selected :attribute is invalid."
not_regex: "The :attribute format is invalid."
numeric: "The :attribute must be a number."
password: "The password is incorrect."
not_regex: "The :attribute field format is invalid."
numeric: "The :attribute field must be a number."
password:
letters: "The :attribute field must contain at least one letter."
mixed: "The :attribute field must contain at least one uppercase and one lowercase letter."
numbers: "The :attribute field must contain at least one number."
symbols: "The :attribute field must contain at least one symbol."
uncompromised: "The given :attribute has appeared in a data leak. Please choose a different :attribute."
present: "The :attribute field must be present."
regex: "The :attribute format is invalid."
present_if: "The :attribute field must be present when :other is :value."
present_unless: "The :attribute field must be present unless :other is :value."
present_with: "The :attribute field must be present when :values is present."
present_with_all: "The :attribute field must be present when :values are present."
prohibited: "The :attribute field is prohibited."
prohibited_if: "The :attribute field is prohibited when :other is :value."
prohibited_unless: "The :attribute field is prohibited unless :other is in :values."
prohibits: "The :attribute field prohibits :other from being present."
regex: "The :attribute field format is invalid."
required: "The :attribute field is required."
required_array_keys: "The :attribute field must contain entries for: :values."
required_if: "The :attribute field is required when :other is :value."
required_if_accepted: "The :attribute field is required when :other is accepted."
required_unless: "The :attribute field is required unless :other is in :values."
required_with: "The :attribute field is required when :values is present."
required_with_all: "The :attribute field is required when :values are present."
required_without: "The :attribute field is required when :values is not present."
required_without_all: "The :attribute field is required when none of :values are present."
prohibited: "The :attribute field is prohibited."
prohibited_if: "The :attribute field is prohibited when :other is :value."
prohibited_unless: "The :attribute field is prohibited unless :other is in :values."
same: "The :attribute and :other must match."
same: "The :attribute field must match :other."
size:
numeric: "The :attribute must be :size."
file: "The :attribute must be :size kilobytes."
string: "The :attribute must be :size characters."
array: "The :attribute must contain :size items."
starts_with: "The :attribute must start with one of the following: :values."
string: "The :attribute must be a string."
timezone: "The :attribute must be a valid zone."
array: "The :attribute field must contain :size items."
file: "The :attribute field must be :size kilobytes."
numeric: "The :attribute field must be :size."
string: "The :attribute field must be :size characters."
starts_with: "The :attribute field must start with one of the following: :values."
string: "The :attribute field must be a string."
timezone: "The :attribute field must be a valid timezone."
unique: "The :attribute has already been taken."
uploaded: "The :attribute failed to upload."
url: "The :attribute format is invalid."
uuid: "The :attribute must be a valid UUID."
uppercase: "The :attribute field must be uppercase."
url: "The :attribute field must be a valid URL."
ulid: "The :attribute field must be a valid ULID."
uuid: "The :attribute field must be a valid UUID."
attributes:
username: username

View File

@@ -9,10 +9,7 @@
namespace Flarum\Api;
use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\NotificationSerializer;
use Flarum\Api\Endpoint\EndpointInterface;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ErrorHandling\JsonApiFormatter;
use Flarum\Foundation\ErrorHandling\Registry;
@@ -24,6 +21,8 @@ use Flarum\Http\RouteHandlerFactory;
use Flarum\Http\UrlGenerator;
use Illuminate\Contracts\Container\Container;
use Laminas\Stratigility\MiddlewarePipe;
use ReflectionClass;
use Tobyz\JsonApiServer\Endpoint\Endpoint;
class ApiServiceProvider extends AbstractServiceProvider
{
@@ -33,9 +32,40 @@ class ApiServiceProvider extends AbstractServiceProvider
return $url->addCollection('api', $container->make('flarum.api.routes'), 'api');
});
$this->container->singleton('flarum.api.routes', function () {
$this->container->singleton('flarum.api.resources', function () {
return [
Resource\ForumResource::class,
Resource\UserResource::class,
Resource\GroupResource::class,
Resource\PostResource::class,
Resource\DiscussionResource::class,
Resource\NotificationResource::class,
Resource\AccessTokenResource::class,
Resource\MailSettingResource::class,
Resource\ExtensionReadmeResource::class,
];
});
$this->container->singleton('flarum.api.resource_handler', function (Container $container) {
$resources = $this->container->make('flarum.api.resources');
$api = new JsonApi('/');
$api->container($container);
foreach ($resources as $resourceClass) {
/** @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource */
$resource = $container->make($resourceClass);
$api->resource($resource->boot($api));
}
return $api;
});
$this->container->alias('flarum.api.resource_handler', JsonApi::class);
$this->container->singleton('flarum.api.routes', function (Container $container) {
$routes = new RouteCollection;
$this->populateRoutes($routes);
$this->populateRoutes($routes, $container);
return $routes;
});
@@ -68,7 +98,8 @@ class ApiServiceProvider extends AbstractServiceProvider
'flarum.api.route_resolver',
'flarum.api.check_for_maintenance',
HttpMiddleware\CheckCsrfToken::class,
Middleware\ThrottleApi::class
Middleware\ThrottleApi::class,
HttpMiddleware\PopulateWithActor::class,
];
});
@@ -107,12 +138,6 @@ class ApiServiceProvider extends AbstractServiceProvider
return $pipe;
});
$this->container->singleton('flarum.api.notification_serializers', function () {
return [
'discussionRenamed' => BasicDiscussionSerializer::class
];
});
$this->container->singleton('flarum.api_client.exclude_middleware', function () {
return [
HttpMiddleware\InjectActorReference::class,
@@ -144,27 +169,47 @@ class ApiServiceProvider extends AbstractServiceProvider
public function boot(Container $container): void
{
$this->setNotificationSerializers();
AbstractSerializeController::setContainer($container);
AbstractSerializer::setContainer($container);
//
}
protected function setNotificationSerializers(): void
protected function populateRoutes(RouteCollection $routes, Container $container): void
{
$serializers = $this->container->make('flarum.api.notification_serializers');
foreach ($serializers as $type => $serializer) {
NotificationSerializer::setSubjectSerializer($type, $serializer);
}
}
protected function populateRoutes(RouteCollection $routes): void
{
$factory = $this->container->make(RouteHandlerFactory::class);
/** @var RouteHandlerFactory $factory */
$factory = $container->make(RouteHandlerFactory::class);
$callback = include __DIR__.'/routes.php';
$callback($routes, $factory);
$resources = $this->container->make('flarum.api.resources');
foreach ($resources as $resourceClass) {
/**
* This is an empty shell instance,
* we only need it to get the endpoint routes and types.
*
* We avoid dependency injection here to avoid early resolution.
*
* @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource
*/
$resource = (new ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
$type = $resource->type();
/**
* None of the injected dependencies should be directly used within
* the `endpoints` method. Encourage using callbacks.
*
* @var array<Endpoint&EndpointInterface> $endpoints
*/
$endpoints = $resource->resolveEndpoints(true);
foreach ($endpoints as $endpoint) {
$method = $endpoint->method;
$path = rtrim("/$type$endpoint->path", '/');
$name = "$type.$endpoint->name";
$routes->addRoute($method, $path, $name, $factory->toApiResource($resource::class, $endpoint->name));
}
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api;
use Flarum\Http\RequestUtil;
use Flarum\Search\SearchResults;
use Flarum\User\User;
use Tobyz\JsonApiServer\Context as BaseContext;
class Context extends BaseContext
{
protected ?SearchResults $search = null;
/**
* Data passed internally when reusing resource endpoint logic.
*/
protected array $internal = [];
/**
* Parameters mutated on the current instance.
* Useful for passing information between different field callbacks.
*/
protected array $parameters = [];
public function withSearchResults(SearchResults $search): static
{
$new = clone $this;
$new->search = $search;
return $new;
}
public function withInternal(string $key, mixed $value): static
{
$new = clone $this;
$new->internal[$key] = $value;
return $new;
}
public function getSearchResults(): ?SearchResults
{
return $this->search;
}
public function internal(string $key, mixed $default = null): mixed
{
return $this->internal[$key] ?? $default;
}
public function getActor(): User
{
return RequestUtil::getActor($this->request);
}
public function setParam(string $key, mixed $default = null): static
{
$this->parameters[$key] = $default;
return $this;
}
public function getParam(string $key, mixed $default = null): mixed
{
return $this->parameters[$key] ?? $default;
}
public function creating(string|null $resource = null): bool
{
return $this->endpoint instanceof Endpoint\Create && (! $resource || is_a($this->collection, $resource));
}
public function updating(string|null $resource = null): bool
{
return $this->endpoint instanceof Endpoint\Update && (! $resource || is_a($this->collection, $resource));
}
public function deleting(string|null $resource = null): bool
{
return $this->endpoint instanceof Endpoint\Delete && (! $resource || is_a($this->collection, $resource));
}
public function showing(string|null $resource = null): bool
{
return $this->endpoint instanceof Endpoint\Show && (! $resource || is_a($this->collection, $resource));
}
public function listing(string|null $resource = null): bool
{
return $this->endpoint instanceof Endpoint\Index && (! $resource || is_a($this->collection, $resource));
}
}

View File

@@ -1,21 +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\Api\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
abstract class AbstractCreateController extends AbstractShowController
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
return parent::handle($request)->withStatus(201);
}
}

View File

@@ -1,46 +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\Api\Controller;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Collection;
use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\ElementInterface;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractListController extends AbstractSerializeController
{
protected function createElement(mixed $data, SerializerInterface $serializer): ElementInterface
{
return new Collection($data, $serializer);
}
abstract protected function data(ServerRequestInterface $request, Document $document): iterable;
protected function addPaginationData(Document $document, ServerRequestInterface $request, string $url, ?int $total): void
{
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$document->addPaginationLinks(
$url,
$request->getQueryParams(),
$offset,
$limit,
$total,
);
$document->setMeta([
'total' => $total,
'perPage' => $limit,
'page' => $offset / $limit + 1,
]);
}
}

View File

@@ -1,432 +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\Api\Controller;
use Flarum\Api\JsonApiResponse;
use Flarum\Api\Serializer\AbstractSerializer;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\ElementInterface;
use Tobscure\JsonApi\Parameters;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractSerializeController implements RequestHandlerInterface
{
/**
* The name of the serializer class to output results with.
*
* @var class-string<AbstractSerializer>|null
*/
public ?string $serializer;
/**
* The relationships that are included by default.
*
* @var string[]
*/
public array $include = [];
/**
* The relationships that are available to be included.
*
* @var string[]
*/
public array $optionalInclude = [];
/**
* The maximum number of records that can be requested.
*/
public int $maxLimit = 50;
/**
* The number of records included by default.
*/
public int $limit = 20;
/**
* The fields that are available to be sorted by.
*
* @var string[]
*/
public array $sortFields = [];
/**
* The default sort field and order to use.
*
* @var array<string, string>|null
*/
public ?array $sort = null;
protected static Container $container;
/**
* @var array<class-string<self>, callable[]>
*/
protected static array $beforeDataCallbacks = [];
/**
* @var array<class-string<self>, callable[]>
*/
protected static array $beforeSerializationCallbacks = [];
/**
* @var string[][]
*/
protected static array $loadRelations = [];
/**
* @var array<string, callable>
*/
protected static array $loadRelationCallables = [];
public function handle(ServerRequestInterface $request): ResponseInterface
{
$document = new Document;
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$beforeDataCallbacks[$class])) {
foreach (static::$beforeDataCallbacks[$class] as $callback) {
$callback($this);
}
}
}
$data = $this->data($request, $document);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$beforeSerializationCallbacks[$class])) {
foreach (static::$beforeSerializationCallbacks[$class] as $callback) {
$callback($this, $data, $request, $document);
}
}
}
if (empty($this->serializer)) {
throw new InvalidArgumentException('Serializer required for controller: '.static::class);
}
$serializer = static::$container->make($this->serializer);
$serializer->setRequest($request);
$element = $this->createElement($data, $serializer)
->with($this->extractInclude($request))
->fields($this->extractFields($request));
$document->setData($element);
return new JsonApiResponse($document);
}
/**
* Get the data to be serialized and assigned to the response document.
*/
abstract protected function data(ServerRequestInterface $request, Document $document): mixed;
/**
* Create a PHP JSON-API Element for output in the document.
*/
abstract protected function createElement(mixed $data, SerializerInterface $serializer): ElementInterface;
/**
* Returns the relations to load added by extenders.
*
* @return string[]
*/
protected function getRelationsToLoad(Collection $models): array
{
$addedRelations = [];
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$loadRelations[$class])) {
$addedRelations = array_merge($addedRelations, static::$loadRelations[$class]);
}
}
return $addedRelations;
}
/**
* Returns the relation callables to load added by extenders.
*
* @return array<string, callable>
*/
protected function getRelationCallablesToLoad(Collection $models): array
{
$addedRelationCallables = [];
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$loadRelationCallables[$class])) {
$addedRelationCallables = array_merge($addedRelationCallables, static::$loadRelationCallables[$class]);
}
}
return $addedRelationCallables;
}
/**
* Eager loads the required relationships.
*/
protected function loadRelations(Collection $models, array $relations, ServerRequestInterface $request = null): void
{
$addedRelations = $this->getRelationsToLoad($models);
$addedRelationCallables = $this->getRelationCallablesToLoad($models);
foreach ($addedRelationCallables as $name => $relation) {
$addedRelations[] = $name;
}
if (! empty($addedRelations)) {
usort($addedRelations, function ($a, $b) {
return substr_count($a, '.') - substr_count($b, '.');
});
foreach ($addedRelations as $relation) {
if (str_contains($relation, '.')) {
$parentRelation = Str::beforeLast($relation, '.');
if (! in_array($parentRelation, $relations, true)) {
continue;
}
}
$relations[] = $relation;
}
}
if (! empty($relations)) {
$relations = array_unique($relations);
}
$callableRelations = [];
$nonCallableRelations = [];
foreach ($relations as $relation) {
if (isset($addedRelationCallables[$relation])) {
$load = $addedRelationCallables[$relation];
$callableRelations[$relation] = function ($query) use ($load, $request, $relations) {
$load($query, $request, $relations);
};
} else {
$nonCallableRelations[] = $relation;
}
}
if (! empty($callableRelations)) {
$models->loadMissing($callableRelations);
}
if (! empty($nonCallableRelations)) {
$models->loadMissing($nonCallableRelations);
}
}
/**
* @throws \Tobscure\JsonApi\Exception\InvalidParameterException
*/
protected function extractInclude(ServerRequestInterface $request): array
{
$available = array_merge($this->include, $this->optionalInclude);
return $this->buildParameters($request)->getInclude($available) ?: $this->include;
}
protected function extractFields(ServerRequestInterface $request): array
{
return $this->buildParameters($request)->getFields();
}
/**
* @throws \Tobscure\JsonApi\Exception\InvalidParameterException
*/
protected function extractSort(ServerRequestInterface $request): ?array
{
return $this->buildParameters($request)->getSort($this->sortFields) ?: $this->sort;
}
/**
* @throws \Tobscure\JsonApi\Exception\InvalidParameterException
*/
protected function extractOffset(ServerRequestInterface $request): int
{
return (int) $this->buildParameters($request)->getOffset($this->extractLimit($request)) ?: 0;
}
/**
* @throws \Tobscure\JsonApi\Exception\InvalidParameterException
*/
protected function extractLimit(ServerRequestInterface $request): int
{
return (int) $this->buildParameters($request)->getLimit($this->maxLimit) ?: $this->limit;
}
protected function extractFilter(ServerRequestInterface $request): array
{
return $this->buildParameters($request)->getFilter() ?: [];
}
protected function buildParameters(ServerRequestInterface $request): Parameters
{
return new Parameters($request->getQueryParams());
}
protected function sortIsDefault(ServerRequestInterface $request): bool
{
return ! Arr::get($request->getQueryParams(), 'sort');
}
/**
* Set the serializer that will serialize data for the endpoint.
*/
public function setSerializer(string $serializer): void
{
$this->serializer = $serializer;
}
/**
* Include the given relationship by default.
*/
public function addInclude(array|string $name): void
{
$this->include = array_merge($this->include, (array) $name);
}
/**
* Don't include the given relationship by default.
*/
public function removeInclude(array|string $name): void
{
$this->include = array_diff($this->include, (array) $name);
}
/**
* Make the given relationship available for inclusion.
*/
public function addOptionalInclude(array|string $name): void
{
$this->optionalInclude = array_merge($this->optionalInclude, (array) $name);
}
/**
* Don't allow the given relationship to be included.
*/
public function removeOptionalInclude(array|string $name): void
{
$this->optionalInclude = array_diff($this->optionalInclude, (array) $name);
}
/**
* Set the default number of results.
*/
public function setLimit(int $limit): void
{
$this->limit = $limit;
}
/**
* Set the maximum number of results.
*/
public function setMaxLimit(int $max): void
{
$this->maxLimit = $max;
}
/**
* Allow sorting results by the given field.
*/
public function addSortField(array|string $field): void
{
$this->sortFields = array_merge($this->sortFields, (array) $field);
}
/**
* Disallow sorting results by the given field.
*/
public function removeSortField(array|string $field): void
{
$this->sortFields = array_diff($this->sortFields, (array) $field);
}
/**
* Set the default sort order for the results.
*/
public function setSort(array $sort): void
{
$this->sort = $sort;
}
public static function getContainer(): Container
{
return static::$container;
}
/**
* @internal
*/
public static function setContainer(Container $container): void
{
static::$container = $container;
}
/**
* @internal
*/
public static function addDataPreparationCallback(string $controllerClass, callable $callback): void
{
if (! isset(static::$beforeDataCallbacks[$controllerClass])) {
static::$beforeDataCallbacks[$controllerClass] = [];
}
static::$beforeDataCallbacks[$controllerClass][] = $callback;
}
/**
* @internal
*/
public static function addSerializationPreparationCallback(string $controllerClass, callable $callback): void
{
if (! isset(static::$beforeSerializationCallbacks[$controllerClass])) {
static::$beforeSerializationCallbacks[$controllerClass] = [];
}
static::$beforeSerializationCallbacks[$controllerClass][] = $callback;
}
/**
* @internal
*/
public static function setLoadRelations(string $controllerClass, array $relations): void
{
if (! isset(static::$loadRelations[$controllerClass])) {
static::$loadRelations[$controllerClass] = [];
}
static::$loadRelations[$controllerClass] = array_merge(static::$loadRelations[$controllerClass], $relations);
}
/**
* @internal
*/
public static function setLoadRelationCallables(string $controllerClass, array $relations): void
{
if (! isset(static::$loadRelationCallables[$controllerClass])) {
static::$loadRelationCallables[$controllerClass] = [];
}
static::$loadRelationCallables[$controllerClass] = array_merge(static::$loadRelationCallables[$controllerClass], $relations);
}
}

View File

@@ -1,21 +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\Api\Controller;
use Tobscure\JsonApi\Resource;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractShowController extends AbstractSerializeController
{
protected function createElement(mixed $data, SerializerInterface $serializer): \Tobscure\JsonApi\ElementInterface
{
return new Resource($data, $serializer);
}
}

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\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\DeveloperAccessToken;
use Flarum\Http\Event\DeveloperTokenCreated;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
/**
* Not to be confused with the CreateTokenController,
* this controller is used by the actor to manually create a developer type access token.
*/
class CreateAccessTokenController extends AbstractCreateController
{
public ?string $serializer = AccessTokenSerializer::class;
public function __construct(
protected Dispatcher $events,
protected Factory $validation
) {
}
public function data(ServerRequestInterface $request, Document $document): DeveloperAccessToken
{
$actor = RequestUtil::getActor($request);
$actor->assertRegistered();
$actor->assertCan('createAccessToken');
$title = Arr::get($request->getParsedBody(), 'data.attributes.title');
$this->validation->make(compact('title'), [
'title' => 'required|string|max:255',
])->validate();
$token = DeveloperAccessToken::generate($actor->id);
$token->title = $title;
$token->last_activity_at = null;
$token->save();
$this->events->dispatch(new DeveloperTokenCreated($token));
return $token;
}
}

View File

@@ -1,62 +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\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Discussion\Command\StartDiscussion;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateDiscussionController extends AbstractCreateController
{
public ?string $serializer = DiscussionSerializer::class;
public array $include = [
'posts',
'user',
'lastPostedUser',
'firstPost',
'lastPost'
];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Discussion
{
$actor = RequestUtil::getActor($request);
$ipAddress = $request->getAttribute('ipAddress');
$discussion = $this->bus->dispatch(
new StartDiscussion($actor, Arr::get($request->getParsedBody(), 'data', []), $ipAddress)
);
// After creating the discussion, we assume that the user has seen all
// the posts in the discussion; thus, we will mark the discussion
// as read if they are logged in.
if ($actor->exists) {
$this->bus->dispatch(
new ReadDiscussion($discussion->id, $actor, 1)
);
}
$this->loadRelations(new Collection([$discussion]), $this->extractInclude($request), $request);
return $discussion;
}
}

View File

@@ -1,36 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\Command\CreateGroup;
use Flarum\Group\Group;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateGroupController extends AbstractCreateController
{
public ?string $serializer = GroupSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Group
{
return $this->bus->dispatch(
new CreateGroup(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
);
}
}

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\Api\Controller;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Http\RequestUtil;
use Flarum\Post\Command\PostReply;
use Flarum\Post\CommentPost;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreatePostController extends AbstractCreateController
{
public ?string $serializer = PostSerializer::class;
public array $include = [
'user',
'discussion',
'discussion.posts',
'discussion.lastPostedUser'
];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): CommentPost
{
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data', []);
$discussionId = (int) Arr::get($data, 'relationships.discussion.data.id');
$ipAddress = $request->getAttribute('ipAddress');
/** @var CommentPost $post */
$post = $this->bus->dispatch(
new PostReply($discussionId, $actor, $data, $ipAddress)
);
// After replying, we assume that the user has seen all of the posts
// in the discussion; thus, we will mark the discussion as read if
// they are logged in.
if ($actor->exists) {
$this->bus->dispatch(
new ReadDiscussion($discussionId, $actor, $post->number)
);
}
$discussion = $post->discussion;
$discussion->setRelation('posts', $discussion->posts()->whereVisibleTo($actor)->orderBy('created_at')->pluck('id'));
$this->loadRelations($post->newCollection([$post]), $this->extractInclude($request), $request);
return $post;
}
}

View File

@@ -1,36 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Http\RequestUtil;
use Flarum\User\Command\RegisterUser;
use Flarum\User\User;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateUserController extends AbstractCreateController
{
public ?string $serializer = CurrentUserSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): User
{
return $this->bus->dispatch(
new RegisterUser(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
);
}
}

View File

@@ -1,46 +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\Api\Controller;
use Flarum\Http\AccessToken;
use Flarum\Http\RequestUtil;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteAccessTokenController extends AbstractDeleteController
{
protected function delete(ServerRequestInterface $request): void
{
$actor = RequestUtil::getActor($request);
$id = Arr::get($request->getQueryParams(), 'id');
$actor->assertRegistered();
$token = AccessToken::query()->findOrFail($id);
/** @var Session|null $session */
$session = $request->getAttribute('session');
// Current session should only be terminated through logout.
if ($session && $token->token === $session->get('access_token')) {
throw new PermissionDeniedException();
}
// Don't give away the existence of the token.
if ($actor->cannot('revoke', $token)) {
throw new ModelNotFoundException();
}
$token->delete();
}
}

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\Api\Controller;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\RequestUtil;
use Flarum\User\Command\DeleteAvatar;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class DeleteAvatarController extends AbstractShowController
{
public ?string $serializer = UserSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): mixed
{
return $this->bus->dispatch(
new DeleteAvatar(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
);
}
}

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\Api\Controller;
use Flarum\Discussion\Command\DeleteDiscussion;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteDiscussionController extends AbstractDeleteController
{
public function __construct(
protected Dispatcher $bus
) {
}
protected function delete(ServerRequestInterface $request): void
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$input = $request->getParsedBody();
$this->bus->dispatch(
new DeleteDiscussion($id, $actor, $input)
);
}
}

View File

@@ -1,31 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Group\Command\DeleteGroup;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteGroupController extends AbstractDeleteController
{
public function __construct(
protected Dispatcher $bus
) {
}
protected function delete(ServerRequestInterface $request): void
{
$this->bus->dispatch(
new DeleteGroup(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
);
}
}

View File

@@ -1,31 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\Post\Command\DeletePost;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeletePostController extends AbstractDeleteController
{
public function __construct(
protected Dispatcher $bus
) {
}
protected function delete(ServerRequestInterface $request): void
{
$this->bus->dispatch(
new DeletePost(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
);
}
}

View File

@@ -1,31 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\User\Command\DeleteUser;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteUserController extends AbstractDeleteController
{
public function __construct(
protected Dispatcher $bus
) {
}
protected function delete(ServerRequestInterface $request): void
{
$this->bus->dispatch(
new DeleteUser(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
);
}
}

View File

@@ -1,53 +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\Api\Controller;
use Flarum\Api\Serializer\AccessTokenSerializer;
use Flarum\Http\AccessToken;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListAccessTokensController extends AbstractListController
{
public ?string $serializer = AccessTokenSerializer::class;
public function __construct(
protected UrlGenerator $url,
protected SearchManager $search
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$actor->assertRegistered();
$offset = $this->extractOffset($request);
$limit = $this->extractLimit($request);
$filter = $this->extractFilter($request);
$tokens = $this->search->query(AccessToken::class, new SearchCriteria($actor, $filter, $limit, $offset));
$document->addPaginationLinks(
$this->url->to('api')->route('access-tokens.index'),
$request->getQueryParams(),
$offset,
$limit,
$tokens->areMoreResults() ? null : 0
);
return $tokens->getResults();
}
}

View File

@@ -1,99 +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\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListDiscussionsController extends AbstractListController
{
public ?string $serializer = DiscussionSerializer::class;
public array $include = [
'user',
'lastPostedUser',
'mostRelevantPost',
'mostRelevantPost.user'
];
public array $optionalInclude = [
'firstPost',
'lastPost'
];
public ?array $sort = ['lastPostedAt' => 'desc'];
public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt'];
public function __construct(
protected SearchManager $search,
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$filters = $this->extractFilter($request);
$sort = $this->extractSort($request);
$sortIsDefault = $this->sortIsDefault($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = array_merge($this->extractInclude($request), ['state']);
$results = $this->search->query(
Discussion::class,
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
);
$this->addPaginationData(
$document,
$request,
$this->url->to('api')->route('discussions.index'),
$results->areMoreResults() ? null : 0
);
Discussion::setStateUser($actor);
// Eager load groups for use in the policies (isAdmin check)
if (in_array('mostRelevantPost.user', $include)) {
$include[] = 'mostRelevantPost.user.groups';
// If the first level of the relationship wasn't explicitly included,
// add it so the code below can look for it
if (! in_array('mostRelevantPost', $include)) {
$include[] = 'mostRelevantPost';
}
}
$results = $results->getResults();
$this->loadRelations($results, $include, $request);
if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) {
foreach ($results as $discussion) {
foreach ($relations as $relation) {
if ($discussion->$relation) {
$discussion->$relation->discussion = $discussion;
}
}
}
}
return $results;
}
}

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\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\Group;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListGroupsController extends AbstractListController
{
public ?string $serializer = GroupSerializer::class;
public array $sortFields = ['nameSingular', 'namePlural', 'isHidden'];
public int $limit = -1;
public function __construct(
protected SearchManager $search,
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$filters = $this->extractFilter($request);
$sort = $this->extractSort($request);
$sortIsDefault = $this->sortIsDefault($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$queryResults = $this->search->query(
Group::class,
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
);
$document->addPaginationLinks(
$this->url->to('api')->route('groups.index'),
$request->getQueryParams(),
$offset,
$limit,
$queryResults->areMoreResults() ? null : 0
);
$results = $queryResults->getResults();
$this->loadRelations($results, [], $request);
return $results;
}
}

View File

@@ -1,100 +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\Api\Controller;
use Flarum\Api\Serializer\NotificationSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Notification\NotificationRepository;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListNotificationsController extends AbstractListController
{
public ?string $serializer = NotificationSerializer::class;
public array $include = [
'fromUser',
'subject',
'subject.discussion'
];
public function __construct(
protected NotificationRepository $notifications,
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$actor->assertRegistered();
$actor->markNotificationsAsRead()->save();
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = $this->extractInclude($request);
if (! in_array('subject', $include)) {
$include[] = 'subject';
}
$notifications = $this->notifications->findByUser($actor, $limit + 1, $offset);
$this->loadRelations($notifications, array_diff($include, ['subject.discussion']), $request);
$notifications = $notifications->all();
$areMoreResults = false;
if (count($notifications) > $limit) {
array_pop($notifications);
$areMoreResults = true;
}
$this->addPaginationData(
$document,
$request,
$this->url->to('api')->route('notifications.index'),
$areMoreResults ? null : 0
);
if (in_array('subject.discussion', $include)) {
$this->loadSubjectDiscussions($notifications);
}
return $notifications;
}
/**
* @param \Flarum\Notification\Notification[] $notifications
*/
private function loadSubjectDiscussions(array $notifications): void
{
$ids = [];
foreach ($notifications as $notification) {
if ($notification->subject && ($discussionId = $notification->subject->getAttribute('discussion_id'))) {
$ids[] = $discussionId;
}
}
$discussions = Discussion::query()->find(array_unique($ids));
foreach ($notifications as $notification) {
if ($notification->subject && ($discussionId = $notification->subject->getAttribute('discussion_id'))) {
$notification->subject->setRelation('discussion', $discussions->find($discussionId));
}
}
}
}

View File

@@ -1,124 +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\Api\Controller;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\Exception\InvalidParameterException;
class ListPostsController extends AbstractListController
{
public ?string $serializer = PostSerializer::class;
public array $include = [
'user',
'user.groups',
'editedUser',
'hiddenUser',
'discussion'
];
public array $sortFields = ['number', 'createdAt'];
public function __construct(
protected SearchManager $search,
protected PostRepository $posts,
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$filters = $this->extractFilter($request);
$sort = $this->extractSort($request);
$sortIsDefault = $this->sortIsDefault($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = $this->extractInclude($request);
$results = $this->search->query(
Post::class,
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
);
$document->addPaginationLinks(
$this->url->to('api')->route('posts.index'),
$request->getQueryParams(),
$offset,
$limit,
$results->areMoreResults() ? null : 0
);
// Eager load discussion for use in the policies,
// eager loading does not affect the JSON response,
// the response only includes relations included in the request.
if (! in_array('discussion', $include)) {
$include[] = 'discussion';
}
if (in_array('user', $include)) {
$include[] = 'user.groups';
}
$results = $results->getResults();
$this->loadRelations($results, $include, $request);
return $results;
}
/**
* @link https://github.com/flarum/framework/pull/3506
*/
protected function extractSort(ServerRequestInterface $request): ?array
{
$sort = [];
foreach ((parent::extractSort($request) ?: []) as $field => $direction) {
$sort["posts.$field"] = $direction;
}
return $sort;
}
protected function extractOffset(ServerRequestInterface $request): int
{
$actor = RequestUtil::getActor($request);
$queryParams = $request->getQueryParams();
$sort = $this->extractSort($request);
$limit = $this->extractLimit($request);
$filter = $this->extractFilter($request);
if (($near = Arr::get($queryParams, 'page.near')) > 1) {
if (count($filter) > 1 || ! isset($filter['discussion']) || $sort) {
throw new InvalidParameterException(
'You can only use page[near] with filter[discussion] and the default sort order'
);
}
$offset = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $actor);
return max(0, $offset - $limit / 2);
}
return parent::extractOffset($request);
}
}

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\Api\Controller;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Flarum\User\User;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListUsersController extends AbstractListController
{
public ?string $serializer = UserSerializer::class;
public array $include = ['groups'];
public array $sortFields = [
'username',
'commentCount',
'discussionCount',
'lastSeenAt',
'joinedAt'
];
public function __construct(
protected SearchManager $search,
protected UrlGenerator $url
) {
}
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$actor->assertCan('searchUsers');
if (! $actor->hasPermission('user.viewLastSeenAt')) {
// If a user cannot see everyone's last online date, we prevent them from sorting by it
// Otherwise this sort field would defeat the privacy setting discloseOnline
// We use remove instead of add so that extensions can still completely disable the sort using the extender
$this->removeSortField('lastSeenAt');
}
$filters = $this->extractFilter($request);
$sort = $this->extractSort($request);
$sortIsDefault = $this->sortIsDefault($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = $this->extractInclude($request);
$results = $this->search->query(
User::class,
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault)
);
$document->addPaginationLinks(
$this->url->to('api')->route('users.index'),
$request->getQueryParams(),
$offset,
$limit,
$results->areMoreResults() ? null : 0
);
$results = $results->getResults();
$this->loadRelations($results, $include, $request);
return $results;
}
}

View File

@@ -1,178 +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\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Http\RequestUtil;
use Flarum\Http\SlugManager;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowDiscussionController extends AbstractShowController
{
public ?string $serializer = DiscussionSerializer::class;
public array $include = [
'user',
'posts',
'posts.discussion',
'posts.user',
'posts.user.groups',
'posts.editedUser',
'posts.hiddenUser'
];
public array $optionalInclude = [
'user',
'lastPostedUser',
'firstPost',
'lastPost'
];
public function __construct(
protected DiscussionRepository $discussions,
protected PostRepository $posts,
protected SlugManager $slugManager
) {
}
protected function data(ServerRequestInterface $request, Document $document): Discussion
{
$discussionId = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$include = $this->extractInclude($request);
if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
$discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($discussionId, $actor);
} else {
$discussion = $this->discussions->findOrFail($discussionId, $actor);
}
// If posts is included or a sub relation of post is included.
if (in_array('posts', $include) || Str::contains(implode(',', $include), 'posts.')) {
$postRelationships = $this->getPostRelationships($include);
$this->includePosts($discussion, $request, $postRelationships);
}
$this->loadRelations(new Collection([$discussion]), array_filter($include, function ($relationship) {
return ! Str::startsWith($relationship, 'posts');
}), $request);
return $discussion;
}
private function includePosts(Discussion $discussion, ServerRequestInterface $request, array $include): void
{
$actor = RequestUtil::getActor($request);
$limit = $this->extractLimit($request);
$offset = $this->getPostsOffset($request, $discussion, $limit);
$allPosts = $this->loadPostIds($discussion, $actor);
$loadedPosts = $this->loadPosts($discussion, $actor, $offset, $limit, $include, $request);
array_splice($allPosts, $offset, $limit, $loadedPosts);
$discussion->setRelation('posts', (new Post)->newCollection($allPosts));
}
private function loadPostIds(Discussion $discussion, User $actor): array
{
return $discussion->posts()->whereVisibleTo($actor)->orderBy('number')->pluck('id')->all();
}
private function getPostRelationships(array $include): array
{
$prefixLength = strlen($prefix = 'posts.');
$relationships = [];
foreach ($include as $relationship) {
if (substr($relationship, 0, $prefixLength) === $prefix) {
$relationships[] = substr($relationship, $prefixLength);
}
}
return $relationships;
}
private function getPostsOffset(ServerRequestInterface $request, Discussion $discussion, int $limit): int
{
$queryParams = $request->getQueryParams();
$actor = RequestUtil::getActor($request);
if (($near = Arr::get($queryParams, 'page.near')) > 1) {
$offset = $this->posts->getIndexForNumber($discussion->id, $near, $actor);
$offset = max(0, $offset - $limit / 2);
} else {
$offset = $this->extractOffset($request);
}
return $offset;
}
private function loadPosts(Discussion $discussion, User $actor, int $offset, int $limit, array $include, ServerRequestInterface $request): array
{
/** @var Builder $query */
$query = $discussion->posts()->whereVisibleTo($actor);
$query->orderBy('number')->skip($offset)->take($limit);
$posts = $query->get();
/** @var Post $post */
foreach ($posts as $post) {
$post->setRelation('discussion', $discussion);
}
$this->loadRelations($posts, $include, $request);
return $posts->all();
}
protected function getRelationsToLoad(Collection $models): array
{
$addedRelations = parent::getRelationsToLoad($models);
if ($models->first() instanceof Discussion) {
return $addedRelations;
}
return $this->getPostRelationships($addedRelations);
}
protected function getRelationCallablesToLoad(Collection $models): array
{
$addedCallableRelations = parent::getRelationCallablesToLoad($models);
if ($models->first() instanceof Discussion) {
return $addedCallableRelations;
}
$postCallableRelationships = $this->getPostRelationships(array_keys($addedCallableRelations));
$relationCallables = array_intersect_key($addedCallableRelations, array_flip(array_map(function ($relation) {
return "posts.$relation";
}, $postCallableRelationships)));
// remove posts. prefix from keys
return array_combine(array_map(function ($relation) {
return substr($relation, 6);
}, array_keys($relationCallables)), array_values($relationCallables));
}
}

View File

@@ -1,37 +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\Api\Controller;
use Flarum\Api\Serializer\ExtensionReadmeSerializer;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowExtensionReadmeController extends AbstractShowController
{
public ?string $serializer = ExtensionReadmeSerializer::class;
public function __construct(
protected ExtensionManager $extensions
) {
}
protected function data(ServerRequestInterface $request, Document $document): ?Extension
{
$extensionName = Arr::get($request->getQueryParams(), 'name');
RequestUtil::getActor($request)->assertAdmin();
return $this->extensions->getExtension($extensionName);
}
}

View File

@@ -9,25 +9,24 @@
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Group\Group;
use Flarum\Http\RequestUtil;
use Flarum\Api\JsonApi;
use Flarum\Api\Resource\ForumResource;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Psr\Http\Server\RequestHandlerInterface;
class ShowForumController extends AbstractShowController
class ShowForumController implements RequestHandlerInterface
{
public ?string $serializer = ForumSerializer::class;
public function __construct(
protected JsonApi $api
) {
}
public array $include = ['groups', 'actor', 'actor.groups'];
protected function data(ServerRequestInterface $request, Document $document): array
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
return [
'groups' => Group::whereVisibleTo($actor)->get(),
'actor' => $actor->isGuest() ? null : $actor
];
return $this->api
->forResource(ForumResource::class)
->forEndpoint('show')
->handle($request);
}
}

View File

@@ -1,38 +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\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\Group;
use Flarum\Group\GroupRepository;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowGroupController extends AbstractShowController
{
public ?string $serializer = GroupSerializer::class;
public function __construct(
protected GroupRepository $groups
) {
}
protected function data(ServerRequestInterface $request, Document $document): Group
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$group = $this->groups->findOrFail($id, $actor);
return $group;
}
}

View File

@@ -9,36 +9,24 @@
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\MailSettingsSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Validation\Factory;
use Flarum\Api\JsonApi;
use Flarum\Api\Resource\MailSettingResource;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Psr\Http\Server\RequestHandlerInterface;
class ShowMailSettingsController extends AbstractShowController
class ShowMailSettingsController implements RequestHandlerInterface
{
public ?string $serializer = MailSettingsSerializer::class;
public function __construct(
protected JsonApi $api
) {
}
protected function data(ServerRequestInterface $request, Document $document): array
public function handle(ServerRequestInterface $request): ResponseInterface
{
RequestUtil::getActor($request)->assertAdmin();
$drivers = array_map(function ($driver) {
return self::$container->make($driver);
}, self::$container->make('mail.supported_drivers'));
$settings = self::$container->make(SettingsRepositoryInterface::class);
$configured = self::$container->make('flarum.mail.configured_driver');
$actual = self::$container->make('mail.driver');
$validator = self::$container->make(Factory::class);
$errors = $configured->validate($settings, $validator);
return [
'drivers' => $drivers,
'sending' => $actual->canSend(),
'errors' => $errors,
];
return $this->api
->forResource(MailSettingResource::class)
->forEndpoint('show')
->handle($request);
}
}

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\Api\Controller;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowPostController extends AbstractShowController
{
public ?string $serializer = PostSerializer::class;
public array $include = [
'user',
'user.groups',
'editedUser',
'hiddenUser',
'discussion'
];
public function __construct(
protected PostRepository $posts
) {
}
protected function data(ServerRequestInterface $request, Document $document): Post
{
$post = $this->posts->findOrFail(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request));
$include = $this->extractInclude($request);
$this->loadRelations(new Collection([$post]), $include, $request);
return $post;
}
}

View File

@@ -1,51 +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\Api\Controller;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Http\SlugManager;
use Flarum\User\User;
use Flarum\User\UserRepository;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowUserController extends AbstractShowController
{
public ?string $serializer = UserSerializer::class;
public array $include = ['groups'];
public function __construct(
protected SlugManager $slugManager,
protected UserRepository $users
) {
}
protected function data(ServerRequestInterface $request, Document $document): User
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
$user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor);
} else {
$user = $this->users->findOrFail($id, $actor);
}
if ($actor->id === $user->id) {
$this->serializer = CurrentUserSerializer::class;
}
return $user;
}
}

View File

@@ -1,74 +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\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Command\EditDiscussion;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdateDiscussionController extends AbstractShowController
{
public ?string $serializer = DiscussionSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Discussion
{
$actor = RequestUtil::getActor($request);
$discussionId = (int) Arr::get($request->getQueryParams(), 'id');
$data = Arr::get($request->getParsedBody(), 'data', []);
/** @var Discussion $discussion */
$discussion = $this->bus->dispatch(
new EditDiscussion($discussionId, $actor, $data)
);
// TODO: Refactor the ReadDiscussion (state) command into EditDiscussion?
// That's what extensions will do anyway.
if ($readNumber = Arr::get($data, 'attributes.lastReadPostNumber')) {
$state = $this->bus->dispatch(
new ReadDiscussion($discussionId, $actor, $readNumber)
);
$discussion = $state->discussion;
}
if ($posts = $discussion->getModifiedPosts()) {
/** @var Collection<int, Post> $posts */
$posts = (new Collection($posts))->load('discussion', 'user');
$discussionPosts = $discussion->posts()->whereVisibleTo($actor)->oldest()->pluck('id')->all();
foreach ($discussionPosts as &$id) {
foreach ($posts as $post) {
if ($id == $post->id) {
$id = $post;
}
}
}
$discussion->setRelation('posts', $discussionPosts);
$this->include = array_merge($this->include, ['posts', 'posts.discussion', 'posts.user']);
}
return $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\Api\Controller;
use Flarum\Api\Serializer\GroupSerializer;
use Flarum\Group\Command\EditGroup;
use Flarum\Group\Group;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdateGroupController extends AbstractShowController
{
public ?string $serializer = GroupSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Group
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data', []);
return $this->bus->dispatch(
new EditGroup($id, $actor, $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\Api\Controller;
use Flarum\Api\Serializer\NotificationSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Notification\Command\ReadNotification;
use Flarum\Notification\Notification;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdateNotificationController extends AbstractShowController
{
public ?string $serializer = NotificationSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Notification
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
return $this->bus->dispatch(
new ReadNotification($id, $actor)
);
}
}

View File

@@ -1,49 +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\Api\Controller;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Http\RequestUtil;
use Flarum\Post\Command\EditPost;
use Flarum\Post\Post;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdatePostController extends AbstractShowController
{
public ?string $serializer = PostSerializer::class;
public array $include = [
'editedUser',
'discussion'
];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Post
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data', []);
$post = $this->bus->dispatch(
new EditPost($id, $actor, $data)
);
$this->loadRelations($post->newCollection([$post]), $this->extractInclude($request), $request);
return $post;
}
}

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\Api\Controller;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\RequestUtil;
use Flarum\User\Command\EditUser;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\User;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdateUserController extends AbstractShowController
{
public ?string $serializer = UserSerializer::class;
public array $include = ['groups'];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): User
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data', []);
if ($actor->id == $id) {
$this->serializer = CurrentUserSerializer::class;
}
// Require the user's current password if they are attempting to change
// their own email address.
if (isset($data['attributes']['email']) && $actor->id == $id) {
$password = (string) Arr::get($request->getParsedBody(), 'meta.password');
if (! $actor->checkPassword($password)) {
throw new NotAuthenticatedException;
}
}
return $this->bus->dispatch(
new EditUser($id, $actor, $data)
);
}
}

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\Api\Controller;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\RequestUtil;
use Flarum\User\Command\UploadAvatar;
use Flarum\User\User;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UploadAvatarController extends AbstractShowController
{
public ?string $serializer = UserSerializer::class;
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): User
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$file = Arr::get($request->getUploadedFiles(), 'avatar');
return $this->bus->dispatch(
new UploadAvatar($id, $file, $actor)
);
}
}

View File

@@ -9,6 +9,7 @@
namespace Flarum\Api\Controller;
use Flarum\Api\JsonApi;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
@@ -23,12 +24,13 @@ class UploadFaviconController extends UploadImageController
protected string $filenamePrefix = 'favicon';
public function __construct(
JsonApi $api,
SettingsRepositoryInterface $settings,
Factory $filesystemFactory,
protected TranslatorInterface $translator,
protected ImageManager $imageManager
) {
parent::__construct($settings, $filesystemFactory);
parent::__construct($api, $settings, $filesystemFactory);
}
protected function makeImage(UploadedFileInterface $file): EncodedImageInterface

View File

@@ -9,6 +9,7 @@
namespace Flarum\Api\Controller;
use Flarum\Api\JsonApi;
use Flarum\Http\RequestUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Factory;
@@ -16,9 +17,9 @@ use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Interfaces\EncodedImageInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Tobscure\JsonApi\Document;
abstract class UploadImageController extends ShowForumController
{
@@ -28,13 +29,16 @@ abstract class UploadImageController extends ShowForumController
protected string $filenamePrefix = '';
public function __construct(
JsonApi $api,
protected SettingsRepositoryInterface $settings,
Factory $filesystemFactory
) {
parent::__construct($api);
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
}
public function data(ServerRequestInterface $request, Document $document): array
public function handle(ServerRequestInterface $request): ResponseInterface
{
RequestUtil::getActor($request)->assertAdmin();
@@ -52,7 +56,7 @@ abstract class UploadImageController extends ShowForumController
$this->settings->set($this->filePathSettingKey, $uploadName);
return parent::data($request, $document);
return parent::handle($request);
}
abstract protected function makeImage(UploadedFileInterface $file): EncodedImageInterface;

View File

@@ -9,6 +9,7 @@
namespace Flarum\Api\Controller;
use Flarum\Api\JsonApi;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Factory;
use Intervention\Image\ImageManager;
@@ -21,11 +22,12 @@ class UploadLogoController extends UploadImageController
protected string $filenamePrefix = 'logo';
public function __construct(
JsonApi $api,
SettingsRepositoryInterface $settings,
Factory $filesystemFactory,
protected ImageManager $imageManager
) {
parent::__construct($settings, $filesystemFactory);
parent::__construct($api, $settings, $filesystemFactory);
}
protected function makeImage(UploadedFileInterface $file): EncodedImageInterface

View File

@@ -0,0 +1,126 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint\Concerns;
use Closure;
use Flarum\Http\RequestUtil;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Resource\AbstractResource;
use Tobyz\JsonApiServer\Schema\Sort;
trait ExtractsListingParams
{
protected ?Closure $extractFilterCallback = null;
protected ?Closure $extractSortCallback = null;
protected ?Closure $extractLimitCallback = null;
protected ?Closure $extractOffsetCallback = null;
public ?int $limit = null;
public int $maxLimit = 50;
public ?string $defaultSort = null;
public function limit(int $limit): static
{
$this->limit = $limit;
return $this;
}
public function maxLimit(int $maxLimit): static
{
$this->maxLimit = $maxLimit;
return $this;
}
public function extractFilter(Closure $callback): self
{
$this->extractFilterCallback = $callback;
return $this;
}
public function extractSort(Closure $callback): self
{
$this->extractSortCallback = $callback;
return $this;
}
public function extractLimit(Closure $callback): self
{
$this->extractLimitCallback = $callback;
return $this;
}
public function extractOffset(Closure $callback): self
{
$this->extractOffsetCallback = $callback;
return $this;
}
public function extractFilterValue(Context $context, array $defaultExtracts): array
{
return $this->extractFilterCallback
? ($this->extractFilterCallback)($context, $defaultExtracts)
: $defaultExtracts['filter'];
}
public function extractSortValue(Context $context, array $defaultExtracts): ?array
{
$visibleSorts = $this->getAvailableSorts($context);
return $this->extractSortCallback
? ($this->extractSortCallback)($context, $defaultExtracts, $visibleSorts)
: $defaultExtracts['sort'];
}
public function extractLimitValue(Context $context, array $defaultExtracts): ?int
{
return $this->extractLimitCallback
? ($this->extractLimitCallback)($context, $defaultExtracts)
: $defaultExtracts['limit'];
}
public function extractOffsetValue(Context $context, array $defaultExtracts): int
{
return $this->extractOffsetCallback
? ($this->extractOffsetCallback)($context, $defaultExtracts)
: $defaultExtracts['offset'];
}
public function defaultExtracts(Context $context): array
{
return [
'filter' => RequestUtil::extractFilter($context->request),
'sort' => RequestUtil::extractSort($context->request, $this->defaultSort, $this->getAvailableSorts($context)),
'limit' => $limit = (RequestUtil::extractLimit($context->request, $this->limit, $this->maxLimit) ?? null),
'offset' => RequestUtil::extractOffset($context->request, $limit),
];
}
public function getAvailableSorts(Context $context): array
{
if (! $context->collection instanceof AbstractResource) {
return [];
}
$asc = collect($context->collection->resolveSorts())
->filter(fn (Sort $field) => $field->isVisible($context))
->pluck('name')
->toArray();
$desc = array_map(fn ($field) => "-$field", $asc);
return array_merge($asc, $desc);
}
}

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\Api\Endpoint\Concerns;
use Closure;
use Flarum\Http\RequestUtil;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\Exception\PermissionDeniedException;
use Tobyz\JsonApiServer\Context;
trait HasAuthorization
{
protected bool|Closure $authenticated = false;
protected null|string|Closure $ability = null;
protected bool $admin = false;
public function authenticated(bool|Closure $condition = true): self
{
$this->authenticated = $condition;
return $this;
}
public function can(null|string|Closure $ability): self
{
$this->ability = $ability;
return $this;
}
public function admin(bool $admin = true): self
{
$this->admin = $admin;
return $this;
}
public function getAuthenticated(Context $context): bool
{
if (is_bool($this->authenticated)) {
return $this->authenticated;
}
return (bool) (isset($context->model)
? ($this->authenticated)($context->model, $context)
: ($this->authenticated)($context));
}
public function getAuthorized(Context $context): string|null
{
if (! is_callable($this->ability)) {
return $this->ability;
}
return isset($context->model)
? ($this->ability)($context->model, $context)
: ($this->ability)($context);
}
/**
* @throws NotAuthenticatedException
* @throws PermissionDeniedException
*/
public function isVisible(Context $context): bool
{
$actor = RequestUtil::getActor($context->request);
if ($this->getAuthenticated($context)) {
$actor->assertRegistered();
}
if ($this->admin) {
$actor->assertAdmin();
}
if ($ability = $this->getAuthorized($context)) {
$actor->assertCan($ability, $context->model);
}
return parent::isVisible($context);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint\Concerns;
use Flarum\Foundation\ContainerUtil;
use Tobyz\JsonApiServer\Context;
trait HasCustomHooks
{
protected function resolveCallable(callable|string $callable, Context $context): callable
{
if (is_string($callable)) {
return ContainerUtil::wrapCallback($callable, $context->api->getContainer());
}
return $callable;
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Create as BaseCreate;
class Create extends BaseCreate implements EndpointInterface
{
use HasAuthorization;
use HasCustomHooks;
public function setUp(): void
{
parent::setUp();
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Delete as BaseDelete;
class Delete extends BaseDelete implements EndpointInterface
{
use HasAuthorization;
use HasCustomHooks;
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Endpoint as BaseEndpoint;
class Endpoint extends BaseEndpoint implements EndpointInterface
{
use HasAuthorization;
use HasCustomHooks;
use ExtractsListingParams;
}

View File

@@ -0,0 +1,18 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
/**
* @mixin \Tobyz\JsonApiServer\Endpoint\Endpoint
*/
interface EndpointInterface
{
//
}

View File

@@ -0,0 +1,86 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Tobyz\JsonApiServer\Endpoint\Index as BaseIndex;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Pagination\Pagination;
class Index extends BaseIndex implements EndpointInterface
{
use HasAuthorization;
use ExtractsListingParams;
use HasCustomHooks;
public function setUp(): void
{
parent::setUp();
$this
->query(function ($query, ?Pagination $pagination, Context $context): Context {
// This model has a searcher API, so we'll use that instead of the default.
// The searcher API allows swapping the default search engine for a custom one.
$search = $context->api->getContainer()->make(SearchManager::class);
$modelClass = $query->getModel()::class;
if ($query instanceof Builder && $search->searchable($modelClass)) {
$actor = $context->getActor();
$extracts = $this->defaultExtracts($context);
$filters = $this->extractFilterValue($context, $extracts);
$sort = $this->extractSortValue($context, $extracts);
$limit = $this->extractLimitValue($context, $extracts);
$offset = $this->extractOffsetValue($context, $extracts);
$sortIsDefault = ! $context->queryParam('sort');
$results = $search->query(
$modelClass,
new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault),
);
$context = $context->withSearchResults($results);
}
// If the model doesn't have a searcher API, we'll just use the default logic.
else {
$context = $context->withQuery($query);
$this->applySorts($query, $context);
$this->applyFilters($query, $context);
$pagination?->apply($query);
}
return $context;
});
}
public function paginate(int $defaultLimit = 20, int $maxLimit = 50): static
{
$this->limit = $defaultLimit;
$this->maxLimit = $maxLimit;
$this->paginationResolver = fn (Context $context) => new OffsetPagination(
$context,
$this->limit,
$this->maxLimit,
);
return $this;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Show as BaseShow;
class Show extends BaseShow implements EndpointInterface
{
use HasAuthorization;
use ExtractsListingParams;
use HasCustomHooks;
public function setUp(): void
{
parent::setUp();
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Endpoint;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Update as BaseUpdate;
class Update extends BaseUpdate implements EndpointInterface
{
use HasAuthorization;
use HasCustomHooks;
public function setUp(): void
{
parent::setUp();
}
}

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\Api;
use Flarum\Api\Endpoint\EndpointInterface;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Container\Container;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\Diactoros\Uri;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Endpoint\Endpoint;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi as BaseJsonApi;
use Tobyz\JsonApiServer\Resource\Collection;
use Tobyz\JsonApiServer\Resource\Resource;
class JsonApi extends BaseJsonApi
{
protected string $resourceClass;
protected string $endpointName;
protected ?Request $baseRequest = null;
protected ?Container $container = null;
public function forResource(string $resourceClass): self
{
$this->resourceClass = $resourceClass;
return $this;
}
public function forEndpoint(string $endpointName): self
{
$this->endpointName = $endpointName;
return $this;
}
protected function makeContext(Request $request): Context
{
if (! $this->endpointName || ! $this->resourceClass || ! class_exists($this->resourceClass)) {
throw new BadRequestException('No resource or endpoint specified');
}
$collection = $this->getCollection($this->resourceClass);
return (new Context($this, $request))
->withCollection($collection)
->withEndpoint($this->findEndpoint($collection));
}
protected function findEndpoint(?Collection $collection): Endpoint&EndpointInterface
{
/** @var Endpoint&EndpointInterface $endpoint */
foreach ($collection->resolveEndpoints() as $endpoint) {
if ($endpoint->name === $this->endpointName) {
return $endpoint;
}
}
throw new BadRequestException('Invalid endpoint specified');
}
public function withRequest(Request $request): self
{
$this->baseRequest = $request;
return $this;
}
public function handle(Request $request): Response
{
$context = $this->makeContext($request);
return $context->endpoint->handle($context);
}
public function process(array $body, array $internal = [], array $options = []): mixed
{
$request = $this->baseRequest ?? ServerRequestFactory::fromGlobals();
if (! empty($options['actor'])) {
$request = RequestUtil::withActor($request, $options['actor']);
}
$resource = $this->getCollection($this->resourceClass);
$request = $request
->withParsedBody([
...$body,
'data' => [
...($request->getParsedBody()['data'] ?? []),
...($body['data'] ?? []),
'type' => $resource instanceof Resource
? $resource->type()
: $resource->name(),
],
]);
$context = $this->makeContext($request)
->withModelId($body['data']['id'] ?? null);
foreach ($internal as $key => $value) {
$context = $context->withInternal($key, $value);
}
$context = $context->withRequest(
$request
->withMethod($context->endpoint->method)
->withUri(new Uri($context->endpoint->path))
);
return $context->endpoint->process($context);
}
public function validateQueryParameters(Request $request): void
{
foreach ($request->getQueryParams() as $key => $value) {
if (
! preg_match('/[^a-z]/', $key) &&
! in_array($key, ['include', 'fields', 'filter', 'page', 'sort'])
) {
throw (new BadRequestException("Invalid query parameter: $key"))->setSource([
'parameter' => $key,
]);
}
}
}
public function typeForModel(string $modelClass): ?string
{
foreach ($this->resources as $resource) {
if ($resource instanceof AbstractDatabaseResource && $resource->model() === $modelClass) {
return $resource->type();
}
}
return null;
}
public function typesForModels(array $modelClasses): array
{
return array_values(array_unique(array_map(fn ($modelClass) => $this->typeForModel($modelClass), $modelClasses)));
}
public function container(Container $container): static
{
$this->container = $container;
return $this;
}
public function getContainer(): ?Container
{
return $this->container;
}
}

View File

@@ -10,11 +10,10 @@
namespace Flarum\Api;
use Laminas\Diactoros\Response\JsonResponse;
use Tobscure\JsonApi\Document;
class JsonApiResponse extends JsonResponse
{
public function __construct(Document $document, $status = 200, array $headers = [], $encodingOptions = 15)
public function __construct(array $document, $status = 200, array $headers = [], $encodingOptions = 15)
{
$headers['content-type'] = 'application/vnd.api+json';

View File

@@ -0,0 +1,205 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Context as FlarumContext;
use Flarum\Api\Resource\Concerns\Bootable;
use Flarum\Api\Resource\Concerns\Extendable;
use Flarum\Api\Resource\Concerns\HasSortMap;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Model;
use RuntimeException;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Laravel\EloquentResource as BaseResource;
/**
* @template M of Model
* @extends BaseResource<M, FlarumContext>
*/
abstract class AbstractDatabaseResource extends BaseResource
{
use Bootable;
use Extendable;
use HasSortMap;
use DispatchEventsTrait {
dispatchEventsFor as traitDispatchEventsFor;
}
abstract public function model(): string;
/** @inheritDoc */
public function newModel(Context $context): object
{
return new ($this->model());
}
public function resource(object $model, Context $context): ?string
{
$baseModel = $this->model();
if ($model instanceof $baseModel) {
return $this->type();
}
return null;
}
public function filters(): array
{
throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.');
}
public function createAction(object $model, Context $context): object
{
$model = parent::createAction($model, $context);
$this->dispatchEventsFor($model, $context->getActor());
return $model;
}
public function updateAction(object $model, Context $context): object
{
$model = parent::updateAction($model, $context);
$this->dispatchEventsFor($model, $context->getActor());
return $model;
}
public function deleteAction(object $model, Context $context): void
{
$this->deleting($model, $context);
$this->delete($model, $context);
$this->deleted($model, $context);
$this->dispatchEventsFor($model, $context->getActor());
}
/**
* @param M $model
* @param FlarumContext $context
* @return M|null
*/
public function creating(object $model, Context $context): ?object
{
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
* @return M|null
*/
public function updating(object $model, Context $context): ?object
{
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
* @return M|null
*/
public function saving(object $model, Context $context): ?object
{
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
* @return M|null
*/
public function saved(object $model, Context $context): ?object
{
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
* @return M|null
*/
public function created(object $model, Context $context): ?object
{
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
* @return M|null
*/
public function updated(object $model, Context $context): ?object
{
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function deleting(object $model, Context $context): void
{
//
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function deleted(object $model, Context $context): void
{
//
}
public function dispatchEventsFor(mixed $entity, User $actor = null): void
{
if (method_exists($entity, 'releaseEvents')) {
$this->traitDispatchEventsFor($entity, $actor);
}
}
/**
* @param FlarumContext $context
*/
public function mutateDataBeforeValidation(Context $context, array $data): array
{
return $data;
}
/**
* @param FlarumContext $context
*/
public function results(object $query, Context $context): iterable
{
if ($results = $context->getSearchResults()) {
return $results->getResults();
}
return $query->get();
}
/**
* @param FlarumContext $context
*/
public function count(object $query, Context $context): ?int
{
if ($results = $context->getSearchResults()) {
return $results->getTotalResults();
}
return parent::count($query, $context);
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Context;
use Flarum\Api\Resource\Concerns\Bootable;
use Flarum\Api\Resource\Concerns\Extendable;
use Flarum\Api\Resource\Concerns\HasSortMap;
use Tobyz\JsonApiServer\Resource\AbstractResource as BaseResource;
/**
* @template M of object
* @extends BaseResource<M, Context>
*/
abstract class AbstractResource extends BaseResource
{
use Bootable;
use Extendable;
use HasSortMap;
}

View File

@@ -0,0 +1,143 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Http\AccessToken;
use Flarum\Http\DeveloperAccessToken;
use Flarum\Http\Event\DeveloperTokenCreated;
use Flarum\Http\RememberAccessToken;
use Flarum\Http\SessionAccessToken;
use Flarum\Locale\TranslatorInterface;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Jenssegers\Agent\Agent;
/**
* @extends AbstractDatabaseResource<AccessToken>
*/
class AccessTokenResource extends AbstractDatabaseResource
{
public function __construct(
protected TranslatorInterface $translator
) {
}
public function type(): string
{
return 'access-tokens';
}
public function model(): string
{
return AccessToken::class;
}
public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function newModel(\Tobyz\JsonApiServer\Context $context): object
{
if ($context->creating(self::class)) {
$token = DeveloperAccessToken::make($context->getActor()->id);
$token->last_activity_at = null;
return $token;
}
return parent::newModel($context);
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->authenticated()
->can('createAccessToken'),
Endpoint\Delete::make()
->authenticated(),
Endpoint\Index::make()
->authenticated()
->paginate(),
];
}
public function fields(): array
{
return [
Schema\Str::make('token')
->visible(function (AccessToken $token, Context $context) {
return $context->getActor()->id === $token->user_id && ! in_array('token', $token->getHidden(), true);
}),
Schema\Integer::make('userId'),
Schema\DateTime::make('createdAt'),
Schema\DateTime::make('lastActivityAt'),
Schema\Boolean::make('isCurrent')
->get(function (AccessToken $token, Context $context) {
return $token->token === $context->request->getAttribute('session')->get('access_token');
}),
Schema\Boolean::make('isSessionToken')
->get(function (AccessToken $token) {
return in_array($token->type, [SessionAccessToken::$type, RememberAccessToken::$type], true);
}),
Schema\Str::make('title')
->writableOnCreate()
->requiredOnCreate()
->maxLength(255),
Schema\Str::make('lastIpAddress'),
Schema\Str::make('device')
->get(function (AccessToken $token) {
$agent = new Agent();
$agent->setUserAgent($token->last_user_agent);
return $this->translator->trans('core.forum.security.browser_on_operating_system', [
'browser' => $agent->browser(),
'os' => $agent->platform(),
]);
}),
];
}
public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$this->events->dispatch(new DeveloperTokenCreated($model));
return parent::created($model, $context);
}
/**
* @param AccessToken $model
* @param \Flarum\Api\Context $context
* @throws PermissionDeniedException
*/
public function delete(object $model, \Tobyz\JsonApiServer\Context $context): void
{
/** @var Session|null $session */
$session = $context->request->getAttribute('session');
// Current session should only be terminated through logout.
if ($session && $model->token === $session->get('access_token')) {
throw new PermissionDeniedException();
}
// Don't give away the existence of the token.
if ($context->getActor()->cannot('revoke', $model)) {
throw new ModelNotFoundException();
}
$model->delete();
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource\Concerns;
use Flarum\Api\JsonApi;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Validation\Factory;
trait Bootable
{
protected JsonApi $api;
protected Dispatcher $events;
protected Factory $validation;
/**
* Avoids polluting the constructor of the resource with dependencies.
*/
public function boot(JsonApi $api): static
{
$this->api = $api;
$this->events = $api->getContainer()->make(Dispatcher::class);
$this->validation = $api->getContainer()->make(Factory::class);
return $this;
}
/**
* Called by the JSON:API server package to resolve the validation factory.
*/
public function validationFactory(): Factory
{
return $this->validation;
}
}

View File

@@ -0,0 +1,93 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource\Concerns;
trait Extendable
{
protected static array $endpointModifiers = [];
protected static array $fieldModifiers = [];
protected static array $sortModifiers = [];
protected ?array $cachedEndpoints = null;
protected ?array $cachedFields = null;
protected ?array $cachedSorts = null;
public static function mutateEndpoints(callable $modifier): void
{
static::$endpointModifiers[static::class][] = $modifier;
}
public static function mutateFields(callable $modifier): void
{
static::$fieldModifiers[static::class][] = $modifier;
}
public static function mutateSorts(callable $modifier): void
{
static::$sortModifiers[static::class][] = $modifier;
}
public function resolveEndpoints(bool $earlyResolution = false): array
{
if (! is_null($this->cachedEndpoints) && ! $earlyResolution) {
return $this->cachedEndpoints;
}
$endpoints = $this->endpoints();
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$endpointModifiers[$class])) {
foreach (static::$endpointModifiers[$class] as $modifier) {
$endpoints = $modifier($endpoints, $this);
}
}
}
return $this->cachedEndpoints = $endpoints;
}
public function resolveFields(): array
{
if (! is_null($this->cachedFields)) {
return $this->cachedFields;
}
$fields = $this->fields();
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$fieldModifiers[$class])) {
foreach (static::$fieldModifiers[$class] as $modifier) {
$fields = $modifier($fields, $this);
}
}
}
return $this->cachedFields = $fields;
}
public function resolveSorts(): array
{
if (! is_null($this->cachedSorts)) {
return $this->cachedSorts;
}
$sorts = $this->sorts();
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$sortModifiers[$class])) {
foreach (static::$sortModifiers[$class] as $modifier) {
$sorts = $modifier($sorts, $this);
}
}
}
return $this->cachedSorts = $sorts;
}
}

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\Api\Resource\Concerns;
use Flarum\Api\Sort\SortColumn;
trait HasSortMap
{
public function sortMap(): array
{
/** @var SortColumn[] $sorts */
$sorts = $this->resolveSorts();
$map = [];
foreach ($sorts as $sort) {
$map = array_merge($map, $sort->sortMap());
}
return $map;
}
}

View File

@@ -0,0 +1,368 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Carbon\Carbon;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\JsonApi;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Bus\Dispatcher;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Deleting;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Event\Started;
use Flarum\Http\SlugManager;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
/**
* @extends AbstractDatabaseResource<Discussion>
*/
class DiscussionResource extends AbstractDatabaseResource
{
public function __construct(
protected Dispatcher $bus,
protected SlugManager $slugManager,
protected PostRepository $posts
) {
}
public function type(): string
{
return 'discussions';
}
public function model(): string
{
return Discussion::class;
}
public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object
{
$actor = $context->getActor();
if (Arr::get($context->request->getQueryParams(), 'bySlug', false)) {
$discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($id, $actor);
} else {
$discussion = $this->query($context)->findOrFail($id);
}
return $discussion;
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->authenticated()
->can('startDiscussion')
->defaultInclude([
'posts',
'user',
'lastPostedUser',
'firstPost',
'lastPost'
]),
Endpoint\Update::make()
->authenticated(),
Endpoint\Delete::make()
->authenticated()
->can('delete'),
Endpoint\Show::make()
->defaultInclude([
'user',
'posts',
'posts.discussion',
'posts.user',
'posts.user.groups',
'posts.editedUser',
'posts.hiddenUser'
]),
Endpoint\Index::make()
->defaultInclude([
'user',
'lastPostedUser',
'mostRelevantPost',
'mostRelevantPost.user'
])
->defaultSort('-lastPostedAt')
->eagerLoad('state')
->paginate(),
];
}
public function fields(): array
{
return [
Schema\Str::make('title')
->requiredOnCreate()
->writable(function (Discussion $discussion, Context $context) {
return $context->creating()
|| $context->getActor()->can('rename', $discussion);
})
->minLength(3)
->maxLength(80),
Schema\Str::make('content')
->writableOnCreate()
->requiredOnCreate()
->visible(false)
->maxLength(63000)
// set nothing...
->set(fn () => null),
Schema\Str::make('slug')
->get(function (Discussion $discussion) {
return $this->slugManager->forResource(Discussion::class)->toSlug($discussion);
}),
Schema\Integer::make('commentCount'),
Schema\Integer::make('participantCount'),
Schema\DateTime::make('createdAt'),
Schema\DateTime::make('lastPostedAt'),
Schema\Integer::make('lastPostNumber'),
Schema\Boolean::make('canReply')
->get(function (Discussion $discussion, Context $context) {
return $context->getActor()->can('reply', $discussion);
}),
Schema\Boolean::make('canRename')
->get(function (Discussion $discussion, Context $context) {
return $context->getActor()->can('rename', $discussion);
}),
Schema\Boolean::make('canDelete')
->get(function (Discussion $discussion, Context $context) {
return $context->getActor()->can('delete', $discussion);
}),
Schema\Boolean::make('canHide')
->get(function (Discussion $discussion, Context $context) {
return $context->getActor()->can('hide', $discussion);
}),
Schema\Boolean::make('isHidden')
->visible(fn (Discussion $discussion) => $discussion->hidden_at !== null)
->writable(function (Discussion $discussion, Context $context) {
return $context->updating()
&& $context->getActor()->can('hide', $discussion);
})
->set(function (Discussion $discussion, bool $value, Context $context) {
if ($value) {
$discussion->hide($context->getActor());
} else {
$discussion->restore();
}
}),
Schema\DateTime::make('hiddenAt')
->visible(fn (Discussion $discussion) => $discussion->hidden_at !== null),
Schema\DateTime::make('lastReadAt')
->visible(fn (Discussion $discussion) => $discussion->state !== null)
->get(function (Discussion $discussion) {
return $discussion->state->last_read_at;
}),
Schema\Integer::make('lastReadPostNumber')
->visible(fn (Discussion $discussion) => $discussion->state !== null)
->get(function (Discussion $discussion) {
return $discussion->state?->last_read_post_number;
})
->writable(function (Discussion $discussion, Context $context) {
return $context->updating();
})
->set(function (Discussion $discussion, int $value, Context $context) {
if ($readNumber = Arr::get($context->body(), 'data.attributes.lastReadPostNumber')) {
$discussion->afterSave(function (Discussion $discussion) use ($readNumber, $context) {
$this->bus->dispatch(
new ReadDiscussion($discussion->id, $context->getActor(), $readNumber)
);
});
}
}),
Schema\Relationship\ToOne::make('user')
->writableOnCreate()
->includable(),
Schema\Relationship\ToOne::make('firstPost')
->includable()
->inverse('discussion')
->type('posts'),
Schema\Relationship\ToOne::make('lastPostedUser')
->includable()
->type('users'),
Schema\Relationship\ToOne::make('lastPost')
->includable()
->inverse('discussion')
->type('posts'),
Schema\Relationship\ToMany::make('posts')
->withLinkage(function (Context $context) {
return $context->showing(self::class);
})
->includable()
->get(function (Discussion $discussion, Context $context) {
$showingDiscussion = $context->showing(self::class);
if (! $showingDiscussion) {
return fn () => $discussion->posts->all();
}
/** @var Endpoint\Show $endpoint */
$endpoint = $context->endpoint;
$actor = $context->getActor();
$limit = PostResource::$defaultLimit;
if (($near = Arr::get($context->request->getQueryParams(), 'page.near')) > 1) {
$offset = $this->posts->getIndexForNumber($discussion->id, $near, $actor);
$offset = max(0, $offset - $limit / 2);
} else {
$offset = $endpoint->extractOffsetValue($context, $endpoint->defaultExtracts($context));
}
$posts = $discussion->posts()
->whereVisibleTo($actor)
->with($context->endpoint->getEagerLoadsFor('posts', $context))
->with($context->endpoint->getWhereEagerLoadsFor('posts', $context))
->orderBy('number')
->skip($offset)
->take($limit)
->get();
/** @var Post $post */
foreach ($posts as $post) {
$post->setRelation('discussion', $discussion);
}
$allPosts = $discussion->posts()->whereVisibleTo($actor)->orderBy('number')->pluck('id')->all();
$loadedPosts = $posts->all();
array_splice($allPosts, $offset, $limit, $loadedPosts);
return $allPosts;
}),
Schema\Relationship\ToOne::make('mostRelevantPost')
->visible(fn (Discussion $model, Context $context) => $context->listing())
->includable()
->inverse('discussion')
->type('posts'),
Schema\Relationship\ToOne::make('hideUser')
->type('users'),
];
}
public function sorts(): array
{
return [
SortColumn::make('lastPostedAt')
->descendingAlias('latest'),
SortColumn::make('commentCount')
->descendingAlias('top'),
SortColumn::make('createdAt')
->ascendingAlias('oldest')
->descendingAlias('newest'),
];
}
/** @param Discussion $model */
public function creating(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$actor = $context->getActor();
$model->created_at = Carbon::now();
$model->user_id = $actor->id;
$model->setRelation('user', $actor);
$model->raise(new Started($model));
return $model;
}
/** @param Discussion $model */
public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$actor = $context->getActor();
if ($actor->exists) {
$this->bus->dispatch(
new ReadDiscussion($model->id, $actor, 1)
);
}
return $model;
}
/** @param Discussion $model */
protected function saveModel(Model $model, \Tobyz\JsonApiServer\Context $context): void
{
if ($context->creating()) {
$model->newQuery()->getConnection()->transaction(function () use ($model, $context) {
$model->save();
/** @var JsonApi $api */
$api = $context->api;
// Now that the discussion has been created, we can add the first post.
// We will do this by running the PostReply command.
/** @var Post $post */
$post = $api->forResource(PostResource::class)
->forEndpoint('create')
->withRequest($context->request)
->process([
'data' => [
'attributes' => [
'content' => Arr::get($context->body(), 'data.attributes.content'),
],
'relationships' => [
'discussion' => [
'data' => [
'type' => 'discussions',
'id' => (string) $model->id,
],
],
],
],
], ['isFirstPost' => true]);
// Before we dispatch events, refresh our discussion instance's
// attributes as posting the reply will have changed some of them (e.g.
// last_time.)
$model->setRawAttributes($post->discussion->getAttributes(), true);
$model->setFirstPost($post);
$model->setLastPost($post);
$model->save();
});
}
parent::saveModel($model, $context);
}
/** @param Discussion $model */
public function deleting(object $model, \Tobyz\JsonApiServer\Context $context): void
{
$this->events->dispatch(
new Deleting($model, $context->getActor(), [])
);
}
public function saving(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$this->events->dispatch(
new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', []))
);
return $model;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Resource\Findable;
/**
* @todo: change to a simple ExtensionResource with readme field.
*
* @extends AbstractResource<Extension>
*/
class ExtensionReadmeResource extends AbstractResource implements Findable
{
public function __construct(
protected ExtensionManager $extensions
) {
}
public function type(): string
{
return 'extension-readmes';
}
/**
* @param Extension $model
*/
public function getId(object $model, Context $context): string
{
return $model->getId();
}
public function find(string $id, Context $context): ?object
{
return $this->extensions->getExtension($id);
}
public function endpoints(): array
{
return [
Endpoint\Show::make()
->admin(),
];
}
public function fields(): array
{
return [
Schema\Str::make('content')
->get(fn (Extension $extension) => $extension->getReadme()),
];
}
}

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\Api\Resource;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Foundation\Application;
use Flarum\Foundation\Config;
use Flarum\Group\Group;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Cloud;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Contracts\Filesystem\Filesystem;
use stdClass;
use Tobyz\JsonApiServer\Resource\Findable;
/**
* @extends AbstractResource<stdClass>
*/
class ForumResource extends AbstractResource implements Findable
{
/**
* @var Filesystem&Cloud
*/
protected Filesystem $assetsFilesystem;
public function __construct(
protected UrlGenerator $url,
protected SettingsRepositoryInterface $settings,
protected Config $config,
Factory $filesystemFactory
) {
$this->assetsFilesystem = $filesystemFactory->disk('flarum-assets');
}
public function type(): string
{
return 'forums';
}
public function getId(object $model, \Tobyz\JsonApiServer\Context $context): string
{
return '1';
}
public function id(\Tobyz\JsonApiServer\Context $context): ?string
{
return '1';
}
public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object
{
return new stdClass();
}
public function endpoints(): array
{
return [
Endpoint\Show::make()
->defaultInclude(['groups', 'actor.groups'])
->route('GET', '/'),
];
}
public function fields(): array
{
$forumUrl = $this->url->to('forum')->base();
$path = parse_url($forumUrl, PHP_URL_PATH) ?: '';
return [
Schema\Str::make('title')
->get(fn () => $this->settings->get('forum_title')),
Schema\Str::make('description')
->get(fn () => $this->settings->get('forum_description')),
Schema\Boolean::make('showLanguageSelector')
->get(fn () => $this->settings->get('show_language_selector', true)),
Schema\Str::make('baseUrl')
->get(fn () => $forumUrl),
Schema\Str::make('basePath')
->get(fn () => $path),
Schema\Str::make('baseOrigin')
->get(fn () => substr($forumUrl, 0, strlen($forumUrl) - strlen($path))),
Schema\Str::make('debug')
->get(fn () => $this->config->inDebugMode()),
Schema\Str::make('apiUrl')
->get(fn () => $this->url->to('api')->base()),
Schema\Str::make('welcomeTitle')
->get(fn () => $this->settings->get('welcome_title')),
Schema\Str::make('welcomeMessage')
->get(fn () => $this->settings->get('welcome_message')),
Schema\Str::make('themePrimaryColor')
->get(fn () => $this->settings->get('theme_primary_color')),
Schema\Str::make('themeSecondaryColor')
->get(fn () => $this->settings->get('theme_secondary_color')),
Schema\Str::make('logoUrl')
->get(fn () => $this->getLogoUrl()),
Schema\Str::make('faviconUrl')
->get(fn () => $this->getFaviconUrl()),
Schema\Str::make('headerHtml')
->get(fn () => $this->settings->get('custom_header')),
Schema\Str::make('footerHtml')
->get(fn () => $this->settings->get('custom_footer')),
Schema\Boolean::make('allowSignUp')
->get(fn () => $this->settings->get('allow_sign_up')),
Schema\Str::make('defaultRoute')
->get(fn () => $this->settings->get('default_route')),
Schema\Boolean::make('canViewForum')
->get(fn ($model, Context $context) => $context->getActor()->can('viewForum')),
Schema\Boolean::make('canStartDiscussion')
->get(fn ($model, Context $context) => $context->getActor()->can('startDiscussion')),
Schema\Boolean::make('canSearchUsers')
->get(fn ($model, Context $context) => $context->getActor()->can('searchUsers')),
Schema\Boolean::make('canCreateAccessToken')
->get(fn ($model, Context $context) => $context->getActor()->can('createAccessToken')),
Schema\Boolean::make('moderateAccessTokens')
->get(fn ($model, Context $context) => $context->getActor()->can('moderateAccessTokens')),
Schema\Boolean::make('canEditUserCredentials')
->get(fn ($model, Context $context) => $context->getActor()->hasPermission('user.editCredentials')),
Schema\Str::make('assetsBaseUrl')
->get(fn () => rtrim($this->assetsFilesystem->url(''), '/')),
Schema\Str::make('jsChunksBaseUrl')
->get(fn () => $this->assetsFilesystem->url('js')),
Schema\Str::make('adminUrl')
->visible(fn ($model, Context $context) => $context->getActor()->can('administrate'))
->get(fn () => $this->url->to('admin')->base()),
Schema\Str::make('version')
->visible(fn ($model, Context $context) => $context->getActor()->can('administrate'))
->get(fn () => Application::VERSION),
Schema\Relationship\ToMany::make('groups')
->includable()
->get(fn ($model, Context $context) => Group::whereVisibleTo($context->getActor())->get()->all()),
Schema\Relationship\ToOne::make('actor')
->type('users')
->includable()
->get(fn ($model, Context $context) => $context->getActor()->isGuest() ? null : $context->getActor()),
];
}
protected function getLogoUrl(): ?string
{
$logoPath = $this->settings->get('logo_path');
return $logoPath ? $this->getAssetUrl($logoPath) : null;
}
protected function getFaviconUrl(): ?string
{
$faviconPath = $this->settings->get('favicon_path');
return $faviconPath ? $this->getAssetUrl($faviconPath) : null;
}
public function getAssetUrl(string $assetPath): string
{
return $this->assetsFilesystem->url($assetPath);
}
}

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Group\Event\Deleting;
use Flarum\Group\Event\Saving;
use Flarum\Group\Group;
use Flarum\Locale\TranslatorInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Tobyz\JsonApiServer\Context;
/**
* @extends AbstractDatabaseResource<Group>
*/
class GroupResource extends AbstractDatabaseResource
{
public function __construct(
protected TranslatorInterface $translator
) {
}
public function type(): string
{
return 'groups';
}
public function model(): string
{
return Group::class;
}
public function scope(Builder $query, Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->authenticated()
->can('createGroup'),
Endpoint\Update::make()
->authenticated()
->can('edit'),
Endpoint\Delete::make()
->authenticated()
->can('delete'),
Endpoint\Show::make(),
Endpoint\Index::make(),
];
}
public function fields(): array
{
return [
Schema\Str::make('nameSingular')
->requiredOnCreate()
->get(function (Group $group) {
return $this->translateGroupName($group->name_singular);
})
->set(function (Group $group, $value) {
$group->rename($value, null);
})
->writable()
->required(),
Schema\Str::make('namePlural')
->requiredOnCreate()
->get(function (Group $group) {
return $this->translateGroupName($group->name_plural);
})
->set(function (Group $group, $value) {
$group->rename(null, $value);
})
->writable()
->required(),
Schema\Str::make('color')
->nullable()
->writable(),
Schema\Str::make('icon')
->nullable()
->writable(),
Schema\Boolean::make('isHidden')
->writable(),
];
}
public function sorts(): array
{
return [
SortColumn::make('nameSingular'),
SortColumn::make('namePlural'),
SortColumn::make('isHidden'),
];
}
private function translateGroupName(string $name): string
{
$translation = $this->translator->trans($key = 'core.group.'.strtolower($name));
if ($translation !== $key) {
return $translation;
}
return $name;
}
public function saving(object $model, Context $context): ?object
{
$this->events->dispatch(
new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', []))
);
return $model;
}
public function deleting(object $model, Context $context): void
{
$this->events->dispatch(
new Deleting($model, $context->getActor(), [])
);
parent::deleting($model, $context);
}
}

View File

@@ -0,0 +1,88 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Mail\DriverInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Validation\Factory;
use stdClass;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Resource\Findable;
/**
* @extends AbstractResource<object>
*/
class MailSettingResource extends AbstractResource implements Findable
{
public function __construct(
protected SettingsRepositoryInterface $settings,
protected Factory $validator,
protected Container $container
) {
}
public function type(): string
{
return 'mail-settings';
}
public function getId(object $model, Context $context): string
{
return '1';
}
public function id(Context $context): ?string
{
return '1';
}
public function find(string $id, Context $context): ?object
{
return new stdClass();
}
public function endpoints(): array
{
return [
Endpoint\Show::make()
->route('GET', '/')
->admin(),
];
}
public function fields(): array
{
return [
Schema\Arr::make('fields')
->get(function () {
return array_map(fn (DriverInterface $driver) => $driver->availableSettings(), array_map(function ($driver) {
return $this->container->make($driver);
}, $this->container->make('mail.supported_drivers')));
}),
Schema\Boolean::make('sending')
->get(function () {
/** @var DriverInterface $actual */
$actual = $this->container->make('mail.driver');
return $actual->canSend();
}),
Schema\Arr::make('errors')
->get(function () {
/** @var DriverInterface $configured */
$configured = $this->container->make('flarum.mail.configured_driver');
return $configured->validate($this->settings, $this->validator);
}),
];
}
}

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\Api\Resource;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Bus\Dispatcher;
use Flarum\Notification\Command\ReadNotification;
use Flarum\Notification\Notification;
use Flarum\Notification\NotificationRepository;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
/**
* @extends AbstractDatabaseResource<Notification>
*/
class NotificationResource extends AbstractDatabaseResource
{
protected bool $initialized = false;
public function __construct(
protected Dispatcher $bus,
protected NotificationRepository $notifications,
) {
$this->initialized = true;
}
public function type(): string
{
return 'notifications';
}
public function model(): string
{
return Notification::class;
}
public function query(\Tobyz\JsonApiServer\Context $context): object
{
if ($context->listing(self::class)) {
/** @var Endpoint\Index $endpoint */
$endpoint = $context->endpoint;
/** @var OffsetPagination $pagination */
$pagination = ($endpoint->paginationResolver)($context);
return $this->notifications->query($context->getActor(), $pagination->limit, $pagination->offset);
}
return parent::query($context);
}
public function endpoints(): array
{
return [
Endpoint\Update::make()
->authenticated(),
Endpoint\Index::make()
->authenticated()
->before(function (Context $context) {
$context->getActor()->markNotificationsAsRead()->save();
})
->defaultInclude(array_filter([
'fromUser',
'subject',
$this->initialized && count($this->subjectTypes()) > 1
? 'subject.discussion'
: null,
]))
->paginate(),
];
}
public function fields(): array
{
return [
Schema\Str::make('contentType')
->property('type'),
Schema\Arr::make('content')
->property('data'),
Schema\DateTime::make('createdAt'),
Schema\Boolean::make('isRead')
->writable()
->get(fn (Notification $notification) => (bool) $notification->read_at)
->set(function (Notification $notification, bool $value, Context $context) {
$this->bus->dispatch(
new ReadNotification($notification->id, $context->getActor())
);
}),
Schema\Relationship\ToOne::make('user')
->includable(),
Schema\Relationship\ToOne::make('fromUser')
->type('users')
->includable(),
Schema\Relationship\ToOne::make('subject')
->collection($this->subjectTypes())
->includable(),
];
}
protected function subjectTypes(): array
{
return $this->api->typesForModels(
(new Notification())->getSubjectModels()
);
}
}

View File

@@ -0,0 +1,309 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Carbon\Carbon;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Bus\Dispatcher;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Discussion\Discussion;
use Flarum\Foundation\ErrorHandling\LogReporter;
use Flarum\Locale\TranslatorInterface;
use Flarum\Post\CommentPost;
use Flarum\Post\Event\Deleting;
use Flarum\Post\Event\Saving;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Tobyz\JsonApiServer\Exception\BadRequestException;
/**
* @extends AbstractDatabaseResource<Post>
*/
class PostResource extends AbstractDatabaseResource
{
public static int $defaultLimit = 20;
public function __construct(
protected PostRepository $posts,
protected TranslatorInterface $translator,
protected LogReporter $log,
protected Dispatcher $bus
) {
}
public function type(): string
{
return 'posts';
}
public function model(): string
{
return Post::class;
}
public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function newModel(\Tobyz\JsonApiServer\Context $context): object
{
if ($context->creating(self::class)) {
$post = new CommentPost();
$post->user_id = $context->getActor()->id;
$post->ip_address = $context->request->getAttribute('ipAddress');
return $post;
}
return parent::newModel($context);
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->authenticated()
->visible(function (Context $context): bool {
$discussionId = (int) Arr::get($context->body(), 'data.relationships.discussion.data.id');
// Make sure the user has permission to reply to this discussion. First,
// make sure the discussion exists and that the user has permission to
// view it; if not, fail with a ModelNotFound exception so we don't give
// away the existence of the discussion. If the user is allowed to view
// it, check if they have permission to reply.
$discussion = Discussion::query()
->whereVisibleTo($context->getActor())
->findOrFail($discussionId);
// If this is the first post in the discussion, it's technically not a
// "reply", so we won't check for that permission.
if (! $context->internal('isFirstPost')) {
return $context->getActor()->can('reply', $discussion);
}
return true;
})
->defaultInclude([
'user',
'discussion',
'discussion.posts',
'discussion.lastPostedUser'
]),
Endpoint\Update::make()
->authenticated()
->defaultInclude([
'editedUser',
'discussion'
]),
Endpoint\Delete::make()
->authenticated()
->can('delete'),
Endpoint\Show::make()
->defaultInclude([
'user',
'user.groups',
'editedUser',
'hiddenUser',
'discussion'
]),
Endpoint\Index::make()
->extractOffset(function (Context $context, array $defaultExtracts): int {
$queryParams = $context->request->getQueryParams();
if (($near = Arr::get($queryParams, 'page.near')) > 1) {
$sort = $defaultExtracts['sort'];
$filter = $defaultExtracts['filter'];
if (count($filter) > 1 || ! isset($filter['discussion']) || $sort) {
throw new BadRequestException(
'You can only use page[near] with filter[discussion] and the default sort order'
);
}
$limit = $defaultExtracts['limit'];
$offset = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $context->getActor());
return max(0, $offset - $limit / 2);
}
return $defaultExtracts['offset'];
})
->defaultInclude([
'user',
'user.groups',
'editedUser',
'hiddenUser',
'discussion'
])
->paginate(static::$defaultLimit),
];
}
public function fields(): array
{
return [
Schema\Integer::make('number'),
Schema\DateTime::make('createdAt')
->writable(function (Post $post, Context $context) {
return $context->creating()
&& $context->getActor()->isAdmin();
})
->default(fn () => Carbon::now()),
Schema\Str::make('contentType')
->property('type'),
Schema\Str::make('content')
->requiredOnCreate()
->writable(function (Post $post, Context $context) {
return $context->creating() || (
$post instanceof CommentPost
&& $context->updating()
&& $context->getActor()->can('edit', $post)
);
})
->maxLength(63000) // 65535 is without the text formatter XML generated after parsing. So we use 63000 to try being safer.
->visible(function (Post $post, Context $context) {
return ! ($post instanceof CommentPost)
|| $context->getActor()->can('edit', $post);
})
->set(function (Post $post, string $value, Context $context) {
if ($post instanceof CommentPost) {
if ($context->creating()) {
$post->setContentAttribute($value, $context->getActor());
} elseif ($context->updating()) {
$post->revise($value, $context->getActor());
}
}
})
->serialize(function (null|string|array $value, Context $context) {
/**
* Prevent the string type from trying to convert array content (for event posts) to a string.
* @var Schema\Str $field
*/
$field = $context->field;
$field->type = null;
return $value;
}),
Schema\Str::make('contentHtml')
->visible(function (Post $post) {
return $post instanceof CommentPost;
})
->get(function (CommentPost $post, Context $context) {
try {
$rendered = $post->formatContent($context->request);
$post->setAttribute('renderFailed', false);
} catch (\Exception $e) {
$rendered = $this->translator->trans('core.lib.error.render_failed_message');
$this->log->report($e);
$post->setAttribute('renderFailed', true);
}
return $rendered;
}),
Schema\Boolean::make('renderFailed')
->visible(function (Post $post) {
return $post instanceof CommentPost;
}),
Schema\Str::make('ipAddress')
->visible(function (Post $post, Context $context) {
return $post instanceof CommentPost
&& $context->getActor()->can('viewIps', $post);
}),
Schema\DateTime::make('editedAt'),
Schema\Boolean::make('isHidden')
->visible(fn (Post $post) => $post->hidden_at !== null)
->writable(function (Post $post, Context $context) {
return $context->updating()
&& $context->getActor()->can('hide', $post);
})
->set(function (Post $post, bool $value, Context $context) {
if ($post instanceof CommentPost) {
if ($value) {
$post->hide($context->getActor());
} else {
$post->restore();
}
}
}),
Schema\DateTime::make('hiddenAt')
->visible(fn (Post $post) => $post->hidden_at !== null),
Schema\Boolean::make('canEdit')
->get(fn (Post $post, Context $context) => $context->getActor()->can('edit', $post)),
Schema\Boolean::make('canDelete')
->get(fn (Post $post, Context $context) => $context->getActor()->can('delete', $post)),
Schema\Boolean::make('canHide')
->get(fn (Post $post, Context $context) => $context->getActor()->can('hide', $post)),
Schema\Relationship\ToOne::make('user')
->includable(),
Schema\Relationship\ToOne::make('discussion')
->includable()
->writableOnCreate(),
Schema\Relationship\ToOne::make('editedUser')
->type('users')
->includable(),
Schema\Relationship\ToOne::make('hiddenUser')
->type('users')
->includable(),
];
}
public function sorts(): array
{
return [
SortColumn::make('number'),
SortColumn::make('createdAt'),
];
}
/** @param Post $model */
public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$actor = $context->getActor();
// After replying, we assume that the user has seen all of the posts
// in the discussion; thus, we will mark the discussion as read if
// they are logged in.
if ($actor->exists) {
$this->bus->dispatch(
new ReadDiscussion($model->discussion_id, $actor, $model->number)
);
}
return $model;
}
/** @param Post $model */
public function deleting(object $model, \Tobyz\JsonApiServer\Context $context): void
{
$this->events->dispatch(
new Deleting($model, $context->getActor(), [])
);
}
public function saving(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$this->events->dispatch(
new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', []))
);
return $model;
}
}

View File

@@ -0,0 +1,456 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Resource;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Bus\Dispatcher;
use Flarum\Foundation\ValidationException;
use Flarum\Http\SlugManager;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AvatarUploader;
use Flarum\User\Command\DeleteAvatar;
use Flarum\User\Command\UploadAvatar;
use Flarum\User\Event\Deleting;
use Flarum\User\Event\GroupsChanged;
use Flarum\User\Event\RegisteringFromProvider;
use Flarum\User\Event\Saving;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\RegistrationToken;
use Flarum\User\User;
use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use InvalidArgumentException;
/**
* @extends AbstractDatabaseResource<User>
*/
class UserResource extends AbstractDatabaseResource
{
public function __construct(
protected TranslatorInterface $translator,
protected SlugManager $slugManager,
protected SettingsRepositoryInterface $settings,
protected ImageManager $imageManager,
protected AvatarUploader $avatarUploader,
protected Dispatcher $bus,
) {
}
public function type(): string
{
return 'users';
}
public function model(): string
{
return User::class;
}
public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object
{
$actor = $context->getActor();
if (Arr::get($context->request->getQueryParams(), 'bySlug', false)) {
$user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor);
} else {
$user = $this->query($context)->findOrFail($id);
}
return $user;
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->visible(function (Context $context) {
if (! $this->settings->get('allow_sign_up')) {
return $context->getActor()->isAdmin();
}
return true;
}),
Endpoint\Update::make()
->visible(function (User $user, Context $context) {
$actor = $context->getActor();
$body = $context->body();
// Require the user's current password if they are attempting to change
// their own email address.
if (isset($body['data']['attributes']['email']) && $actor->id === $user->id) {
$password = (string) Arr::get($body, 'meta.password');
if (! $actor->checkPassword($password)) {
throw new NotAuthenticatedException;
}
}
$actor->assertRegistered();
return true;
})
->defaultInclude(['groups']),
Endpoint\Delete::make()
->authenticated()
->can('delete'),
Endpoint\Show::make()
->defaultInclude(['groups']),
Endpoint\Index::make()
->can('searchUsers')
->defaultInclude(['groups'])
->paginate(),
Endpoint\Endpoint::make('avatar.upload')
->route('POST', '/{id}/avatar')
->action(function (Context $context) {
$file = Arr::get($context->request->getUploadedFiles(), 'avatar');
return $this->bus->dispatch(
new UploadAvatar((int) $context->modelId, $file, $context->getActor())
);
}),
Endpoint\Endpoint::make('avatar.delete')
->route('DELETE', '/{id}/avatar')
->action(function (Context $context) {
return $this->bus->dispatch(
new DeleteAvatar(Arr::get($context->request->getQueryParams(), 'id'), $context->getActor())
);
}),
];
}
public function fields(): array
{
$translator = $this->translator;
return [
Schema\Str::make('username')
->requiredOnCreateWithout(['token'])
->unique('users', 'username', true)
->regex('/^[a-z0-9_-]+$/i')
->validationMessages([
'username.regex' => $translator->trans('core.api.invalid_username_message'),
'username.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.username')])
])
->minLength(3)
->maxLength(30)
->writable(function (User $user, Context $context) {
return $context->creating()
|| $context->getActor()->can('editCredentials', $user);
})
->set(function (User $user, string $value) {
if ($user->exists) {
$user->rename($value);
} else {
$user->username = $value;
}
}),
Schema\Str::make('email')
->requiredOnCreateWithout(['token'])
->validationMessages([
'email.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.email')])
])
->email(['filter'])
->unique('users', 'email', true)
->visible(function (User $user, Context $context) {
return $context->getActor()->can('editCredentials', $user)
|| $context->getActor()->id === $user->id;
})
->writable(function (User $user, Context $context) {
return $context->creating()
|| $context->getActor()->can('editCredentials', $user)
|| $context->getActor()->id === $user->id;
})
->set(function (User $user, string $value, Context $context) {
if ($user->exists) {
$isSelf = $context->getActor()->id === $user->id;
if ($isSelf) {
$user->requestEmailChange($value);
} else {
$context->getActor()->assertCan('editCredentials', $user);
$user->changeEmail($value);
}
} else {
$user->email = $value;
}
}),
Schema\Boolean::make('isEmailConfirmed')
->visible(function (User $user, Context $context) {
return $context->getActor()->can('editCredentials', $user)
|| $context->getActor()->id === $user->id;
})
->writable(fn (User $user, Context $context) => $context->getActor()->isAdmin())
->set(function (User $user, $value, Context $context) {
if (! empty($value) && ($context->updating() || $context->getActor()->isAdmin())) {
$user->activate();
}
}),
Schema\Str::make('password')
->requiredOnCreateWithout(['token'])
->validationMessages([
'password.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.password')])
])
->minLength(8)
->visible(false)
->writable(function (User $user, Context $context) {
return $context->creating()
|| $context->getActor()->can('editCredentials', $user);
})
->set(function (User $user, ?string $value) {
$user->exists && $user->changePassword($value);
}),
// Registration token.
Schema\Str::make('token')
->visible(false)
->writable(function (User $user, Context $context) {
return $context->creating();
})
->set(function (User $user, ?string $value, Context $context) {
if ($value) {
/** @var RegistrationToken $token */
$token = RegistrationToken::validOrFail($value);
$context->setParam('token', $token);
$user->password ??= Str::random(20);
$this->applyToken($user, $token);
}
})
->save(fn () => null),
Schema\Str::make('displayName'),
Schema\Str::make('avatarUrl'),
Schema\Str::make('slug')
->get(function (User $user) {
return $this->slugManager->forResource(User::class)->toSlug($user);
}),
Schema\DateTime::make('joinTime')
->property('joined_at'),
Schema\Integer::make('discussionCount'),
Schema\Integer::make('commentCount'),
Schema\DateTime::make('lastSeenAt')
->visible(function (User $user, Context $context) {
return $user->getPreference('discloseOnline') || $context->getActor()->can('viewLastSeenAt', $user);
}),
Schema\DateTime::make('markedAllAsReadAt')
->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id)
->writable(fn (User $user, Context $context) => $context->getActor()->id === $user->id)
->set(function (User $user, $value) {
if (! empty($value)) {
$user->markAllAsRead();
}
}),
Schema\Integer::make('unreadNotificationCount')
->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id)
->get(function (User $user): int {
return $user->getUnreadNotificationCount();
}),
Schema\Integer::make('newNotificationCount')
->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id)
->get(function (User $user): int {
return $user->getNewNotificationCount();
}),
Schema\Arr::make('preferences')
->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id)
->writable(fn (User $user, Context $context) => $context->getActor()->id === $user->id)
->set(function (User $user, array $value) {
foreach ($value as $k => $v) {
$user->setPreference($k, $v);
}
}),
Schema\Boolean::make('isAdmin')
->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id)
->get(fn (User $user, Context $context) => $context->getActor()->isAdmin()),
Schema\Boolean::make('canEdit')
->get(function (User $user, Context $context) {
return $context->getActor()->can('edit', $user);
}),
Schema\Boolean::make('canEditCredentials')
->get(function (User $user, Context $context) {
return $context->getActor()->can('editCredentials', $user);
}),
Schema\Boolean::make('canEditGroups')
->get(function (User $user, Context $context) {
return $context->getActor()->can('editGroups', $user);
}),
Schema\Boolean::make('canDelete')
->get(function (User $user, Context $context) {
return $context->getActor()->can('delete', $user);
}),
Schema\Relationship\ToMany::make('groups')
->writable(fn (User $user, Context $context) => $context->updating() && $context->getActor()->can('editGroups', $user))
->includable()
->set(function (User $user, $value, Context $context) {
$actor = $context->getActor();
$oldGroups = $user->groups()->get()->all();
$oldGroupIds = Arr::pluck($oldGroups, 'id');
$newGroupIds = [];
foreach ($value as $group) {
if ($id = Arr::get($group, 'id')) {
$newGroupIds[] = $id;
}
}
// Ensure non-admins aren't adding/removing admins
$adminChanged = in_array('1', array_diff($oldGroupIds, $newGroupIds)) || in_array('1', array_diff($newGroupIds, $oldGroupIds));
$actor->assertPermission(! $adminChanged || $actor->isAdmin());
$user->raise(
new GroupsChanged($user, $oldGroups)
);
$user->afterSave(function (User $user) use ($newGroupIds) {
$user->groups()->sync($newGroupIds);
$user->unsetRelation('groups');
});
}),
];
}
public function sorts(): array
{
return [
SortColumn::make('username'),
SortColumn::make('commentCount'),
SortColumn::make('discussionCount'),
SortColumn::make('lastSeenAt')
->visible(function (Context $context) {
return $context->getActor()->hasPermission('user.viewLastSeenAt');
}),
SortColumn::make('joinedAt'),
];
}
/** @param User $model */
public function saved(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
if (($token = $context->getParam('token')) instanceof RegistrationToken) {
$this->fulfillToken($model, $token);
}
return parent::saved($model, $context);
}
public function deleting(object $model, \Tobyz\JsonApiServer\Context $context): void
{
$this->events->dispatch(
new Deleting($model, $context->getActor(), [])
);
}
public function saving(object $model, \Tobyz\JsonApiServer\Context $context): ?object
{
$this->events->dispatch(
new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', []))
);
return $model;
}
private function applyToken(User $user, RegistrationToken $token): void
{
foreach ($token->user_attributes as $k => $v) {
if ($k === 'avatar_url') {
$this->uploadAvatarFromUrl($user, $v);
continue;
}
$user->$k = $v;
if ($k === 'email') {
$user->activate();
}
}
$this->events->dispatch(
new RegisteringFromProvider($user, $token->provider, $token->payload)
);
}
/**
* @throws InvalidArgumentException
*/
private function uploadAvatarFromUrl(User $user, string $url): void
{
$urlValidator = $this->validation->make(compact('url'), [
'url' => 'required|active_url',
]);
if ($urlValidator->fails()) {
throw new ValidationException([
'avatar_url' => 'Provided avatar URL must be a valid URI.',
]);
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($scheme, ['http', 'https'])) {
throw new ValidationException([
'avatar_url' => "Provided avatar URL must have scheme http or https. Scheme provided was $scheme.",
]);
}
$urlContents = $this->retrieveAvatarFromUrl($url);
if ($urlContents !== null) {
$image = $this->imageManager->read($urlContents);
$this->avatarUploader->upload($user, $image);
}
}
private function retrieveAvatarFromUrl(string $url): ?string
{
$client = new Client();
try {
$response = $client->get($url);
} catch (\Exception $e) {
return null;
}
if ($response->getStatusCode() !== 200) {
return null;
}
return $response->getBody()->getContents();
}
private function fulfillToken(User $user, RegistrationToken $token): void
{
$token->delete();
if ($token->provider && $token->identifier) {
$user->loginProviders()->create([
'provider' => $token->provider,
'identifier' => $token->identifier
]);
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema;
/**
* @todo validation rules for the array items.
*/
class Arr extends Attribute
{
public static function make(string $name): static
{
return (new static($name))
->type(Type\Arr::make())
->rule('array');
}
}

View File

@@ -7,15 +7,11 @@
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Group\Command;
namespace Flarum\Api\Schema;
use Flarum\User\User;
use Tobyz\JsonApiServer\Schema\Field\Attribute as BaseAttribute;
class CreateGroup
class Attribute extends BaseAttribute
{
public function __construct(
public User $actor,
public array $data
) {
}
//
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema;
class Boolean extends Attribute
{
public static function make(string $name): static
{
return (new static($name))
->type(\Tobyz\JsonApiServer\Schema\Type\Boolean::make())
->rule('boolean');
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema;
class Date extends DateTime
{
public static function make(string $name): static
{
return (new static($name))
->type(\Tobyz\JsonApiServer\Schema\Type\Date::make())
->rule('date');
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema;
class DateTime extends Attribute
{
public static function make(string $name): static
{
return (new static($name))
->type(\Tobyz\JsonApiServer\Schema\Type\DateTime::make())
->rule('date');
}
public function before(string $date, bool|callable $condition = true): static
{
return $this->rule('before:'.$date, $condition);
}
public function after(string $date, bool|callable $condition = true): static
{
return $this->rule('after:'.$date, $condition);
}
public function beforeOrEqual(string $date, bool|callable $condition = true): static
{
return $this->rule('before_or_equal:'.$date, $condition);
}
public function afterOrEqual(string $date, bool|callable $condition = true): static
{
return $this->rule('after_or_equal:'.$date, $condition);
}
public function format(string $format, bool|callable $condition = true): static
{
return $this->rule('date_format:'.$format, $condition);
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema;
class Integer extends Number
{
public static function make(string $name): static
{
return (new static($name))
->type(\Tobyz\JsonApiServer\Schema\Type\Integer::make())
->rule('integer');
}
}

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\Api\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\GetsRelationAggregates;
use Tobyz\JsonApiServer\Schema\Contracts\RelationAggregator;
class Number extends Attribute implements RelationAggregator
{
use GetsRelationAggregates;
public static function make(string $name): static
{
return (new static($name))
->type(\Tobyz\JsonApiServer\Schema\Type\Number::make())
->rule('numeric');
}
public function min(int $min, bool|callable $condition = true): static
{
return $this->rule("min:$min", $condition);
}
public function max(int $max, bool|callable $condition = true): static
{
return $this->rule("max:$max", $condition);
}
}

View File

@@ -7,15 +7,11 @@
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Command;
namespace Flarum\Api\Schema\Relationship;
use Flarum\User\User;
use Tobyz\JsonApiServer\Schema\Field\ToMany as BaseToMany;
class RegisterUser
class ToMany extends BaseToMany
{
public function __construct(
public User $actor,
public array $data
) {
}
//
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema\Relationship;
use Tobyz\JsonApiServer\Schema\Field\ToOne as BaseToOne;
class ToOne extends BaseToOne
{
//
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema;
class Str extends Attribute
{
public static function make(string $name): static
{
return (new static($name))
->type(\Tobyz\JsonApiServer\Schema\Type\Str::make())
->rule('string');
}
public function minLength(int $length, bool|callable $condition = true): static
{
return $this->rule('min:'.$length, $condition);
}
public function maxLength(int $length, bool|callable $condition = true): static
{
return $this->rule('max:'.$length, $condition);
}
public function email(array $validators = [], bool|callable $condition = true): static
{
$validators = implode(',', $validators);
if (! empty($validators)) {
$validators = ':'.$validators;
}
return $this->rule("email$validators", $condition);
}
public function regex(string $pattern, bool|callable $condition = true): static
{
return $this->rule("regex:$pattern", $condition);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Schema\Type;
use Tobyz\JsonApiServer\Schema\Type\Type;
class Arr implements Type
{
public static function make(): static
{
return new static();
}
public function serialize(mixed $value): array
{
return (array) $value;
}
public function deserialize(mixed $value): array
{
return (array) $value;
}
public function validate(mixed $value, callable $fail): void
{
if (! is_array($value)) {
$fail('must be an array');
}
}
public function schema(): array
{
return [
'type' => 'array',
];
}
}

View File

@@ -1,234 +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\Api\Serializer;
use Closure;
use DateTime;
use Flarum\Http\RequestUtil;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use LogicException;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobscure\JsonApi\AbstractSerializer as BaseAbstractSerializer;
use Tobscure\JsonApi\Collection;
use Tobscure\JsonApi\Relationship;
use Tobscure\JsonApi\Resource;
use Tobscure\JsonApi\SerializerInterface;
abstract class AbstractSerializer extends BaseAbstractSerializer
{
protected Request $request;
protected User $actor;
protected static Container $container;
/**
* @var array<string, callable[]>
*/
protected static array $attributeMutators = [];
/**
* @var array<string, array<string, callable>>
*/
protected static array $customRelations = [];
public function getRequest(): Request
{
return $this->request;
}
public function setRequest(Request $request): void
{
$this->request = $request;
$this->actor = RequestUtil::getActor($request);
}
public function getActor(): User
{
return $this->actor;
}
public function getAttributes(mixed $model, array $fields = null): array
{
if (! is_object($model) && ! is_array($model)) {
return [];
}
$attributes = $this->getDefaultAttributes($model);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$attributeMutators[$class])) {
foreach (static::$attributeMutators[$class] as $callback) {
$attributes = array_merge(
$attributes,
$callback($this, $model, $attributes)
);
}
}
}
return $attributes;
}
/**
* Get the default set of serialized attributes for a model.
*/
abstract protected function getDefaultAttributes(object|array $model): array;
public function formatDate(DateTime $date = null): ?string
{
return $date?->format(DateTime::RFC3339);
}
public function getRelationship($model, $name)
{
if ($relationship = $this->getCustomRelationship($model, $name)) {
return $relationship;
}
return parent::getRelationship($model, $name);
}
/**
* Get a custom relationship.
*/
protected function getCustomRelationship(object|array $model, string $name): ?Relationship
{
foreach (array_merge([static::class], class_parents($this)) as $class) {
$callback = Arr::get(static::$customRelations, "$class.$name");
if (is_callable($callback)) {
$relationship = $callback($this, $model);
if (isset($relationship) && ! ($relationship instanceof Relationship)) {
throw new LogicException(
'GetApiRelationship handler must return an instance of '.Relationship::class
);
}
return $relationship;
}
}
return null;
}
/**
* Get a relationship builder for a has-one relationship.
*/
public function hasOne(object|array $model, SerializerInterface|Closure|string $serializer, string $relation = null): ?Relationship
{
return $this->buildRelationship($model, $serializer, $relation);
}
/**
* Get a relationship builder for a has-many relationship.
*/
public function hasMany(object|array $model, SerializerInterface|Closure|string $serializer, string $relation = null): ?Relationship
{
return $this->buildRelationship($model, $serializer, $relation, true);
}
protected function buildRelationship(object|array $model, SerializerInterface|Closure|string $serializer, string $relation = null, bool $many = false): ?Relationship
{
if (is_null($relation)) {
list(, , $caller) = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 3);
$relation = $caller['function'];
}
$data = $this->getRelationshipData($model, $relation);
if ($data) {
$serializer = $this->resolveSerializer($serializer, $model, $data);
$type = $many ? Collection::class : Resource::class;
$element = new $type($data, $serializer);
return new Relationship($element);
}
return null;
}
protected function getRelationshipData(object|array $model, string $relation): mixed
{
if (is_object($model)) {
return $model->$relation;
}
return $model[$relation];
}
/**
* @throws InvalidArgumentException
*/
protected function resolveSerializer(SerializerInterface|Closure|string $serializer, object|array $model, mixed $data): SerializerInterface
{
if ($serializer instanceof Closure) {
$serializer = call_user_func($serializer, $model, $data);
}
if (is_string($serializer)) {
$serializer = $this->resolveSerializerClass($serializer);
}
if (! ($serializer instanceof SerializerInterface)) {
throw new InvalidArgumentException('Serializer must be an instance of '
.SerializerInterface::class);
}
return $serializer;
}
protected function resolveSerializerClass(string $class): object
{
$serializer = static::$container->make($class);
$serializer->setRequest($this->request);
return $serializer;
}
public static function getContainer(): Container
{
return static::$container;
}
/**
* @internal
*/
public static function setContainer(Container $container): void
{
static::$container = $container;
}
/**
* @internal
*/
public static function addAttributeMutator(string $serializerClass, callable $callback): void
{
if (! isset(static::$attributeMutators[$serializerClass])) {
static::$attributeMutators[$serializerClass] = [];
}
static::$attributeMutators[$serializerClass][] = $callback;
}
/**
* @internal
*/
public static function setRelationship(string $serializerClass, string $relation, callable $callback): void
{
static::$customRelations[$serializerClass][$relation] = $callback;
}
}

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\Api\Serializer;
use Flarum\Http\AccessToken;
use Flarum\Locale\TranslatorInterface;
use InvalidArgumentException;
use Jenssegers\Agent\Agent;
class AccessTokenSerializer extends AbstractSerializer
{
protected $type = 'access-tokens';
public function __construct(
protected TranslatorInterface $translator
) {
}
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof AccessToken)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.AccessToken::class
);
}
$session = $this->request->getAttribute('session');
$agent = new Agent();
$agent->setUserAgent($model->last_user_agent);
$attributes = [
'token' => $model->token,
'userId' => $model->user_id,
'createdAt' => $this->formatDate($model->created_at),
'lastActivityAt' => $this->formatDate($model->last_activity_at),
'isCurrent' => $session && $session->get('access_token') === $model->token,
'isSessionToken' => in_array($model->type, ['session', 'session_remember'], true),
'title' => $model->title,
'lastIpAddress' => $model->last_ip_address,
'device' => $this->translator->trans('core.forum.security.browser_on_operating_system', [
'browser' => $agent->browser(),
'os' => $agent->platform(),
]),
];
// Unset hidden attributes (like the token value on session tokens)
foreach ($model->getHidden() as $name) {
unset($attributes[$name]);
}
// Hide the token value to non-actors no matter who they are.
if (isset($attributes['token']) && $this->getActor()->id !== $model->user_id) {
unset($attributes['token']);
}
return $attributes;
}
}

View File

@@ -1,77 +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\Api\Serializer;
use Flarum\Discussion\Discussion;
use Flarum\Http\SlugManager;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class BasicDiscussionSerializer extends AbstractSerializer
{
protected $type = 'discussions';
public function __construct(
protected SlugManager $slugManager
) {
}
/**
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Discussion)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.Discussion::class
);
}
return [
'title' => $model->title,
'slug' => $this->slugManager->forResource(Discussion::class)->toSlug($model),
];
}
protected function user(Discussion $discussion): ?Relationship
{
return $this->hasOne($discussion, BasicUserSerializer::class);
}
protected function firstPost(Discussion $discussion): ?Relationship
{
return $this->hasOne($discussion, BasicPostSerializer::class);
}
protected function lastPostedUser(Discussion $discussion): ?Relationship
{
return $this->hasOne($discussion, BasicUserSerializer::class);
}
protected function lastPost(Discussion $discussion): ?Relationship
{
return $this->hasOne($discussion, BasicPostSerializer::class);
}
protected function posts(Discussion $discussion): ?Relationship
{
return $this->hasMany($discussion, PostSerializer::class);
}
protected function mostRelevantPost(Discussion $discussion): ?Relationship
{
return $this->hasOne($discussion, PostSerializer::class);
}
protected function hiddenUser(Discussion $discussion): ?Relationship
{
return $this->hasOne($discussion, BasicUserSerializer::class);
}
}

View File

@@ -1,72 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Serializer;
use Exception;
use Flarum\Foundation\ErrorHandling\LogReporter;
use Flarum\Locale\TranslatorInterface;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class BasicPostSerializer extends AbstractSerializer
{
protected $type = 'posts';
public function __construct(
protected LogReporter $log,
protected TranslatorInterface $translator
) {
}
/**
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Post)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.Post::class
);
}
$attributes = [
'number' => (int) $model->number,
'createdAt' => $this->formatDate($model->created_at),
'contentType' => $model->type
];
if ($model instanceof CommentPost) {
try {
$attributes['contentHtml'] = $model->formatContent($this->request);
$attributes['renderFailed'] = false;
} catch (Exception $e) {
$attributes['contentHtml'] = $this->translator->trans('core.lib.error.render_failed_message');
$this->log->report($e);
$attributes['renderFailed'] = true;
}
} else {
$attributes['content'] = $model->content;
}
return $attributes;
}
protected function user(Post $post): ?Relationship
{
return $this->hasOne($post, BasicUserSerializer::class);
}
protected function discussion(Post $post): ?Relationship
{
return $this->hasOne($post, BasicDiscussionSerializer::class);
}
}

View File

@@ -1,53 +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\Api\Serializer;
use Flarum\Http\SlugManager;
use Flarum\User\User;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class BasicUserSerializer extends AbstractSerializer
{
protected $type = 'users';
public function __construct(
protected SlugManager $slugManager
) {
}
/**
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof User)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.User::class
);
}
return [
'username' => $model->username,
'displayName' => $model->display_name,
'avatarUrl' => $model->avatar_url,
'slug' => $this->slugManager->forResource(User::class)->toSlug($model)
];
}
protected function groups(User $user): Relationship
{
if ($this->getActor()->can('viewHiddenGroups')) {
return $this->hasMany($user, GroupSerializer::class);
}
return $this->hasMany($user, GroupSerializer::class, 'visibleGroups');
}
}

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\Api\Serializer;
use Flarum\User\User;
use InvalidArgumentException;
class CurrentUserSerializer extends UserSerializer
{
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof User)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.User::class
);
}
$attributes = parent::getDefaultAttributes($model);
$attributes += [
'isEmailConfirmed' => (bool) $model->is_email_confirmed,
'email' => $model->email,
'markedAllAsReadAt' => $this->formatDate($model->marked_all_as_read_at),
'unreadNotificationCount' => (int) $model->getUnreadNotificationCount(),
'newNotificationCount' => (int) $model->getNewNotificationCount(),
'preferences' => (array) $model->preferences,
'isAdmin' => $model->isAdmin(),
];
return $attributes;
}
}

View File

@@ -1,49 +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\Api\Serializer;
use Flarum\Discussion\Discussion;
class DiscussionSerializer extends BasicDiscussionSerializer
{
/**
* @param Discussion $model
*/
protected function getDefaultAttributes(object|array $model): array
{
$attributes = parent::getDefaultAttributes($model) + [
'commentCount' => (int) $model->comment_count,
'participantCount' => (int) $model->participant_count,
'createdAt' => $this->formatDate($model->created_at),
'lastPostedAt' => $this->formatDate($model->last_posted_at),
'lastPostNumber' => (int) $model->last_post_number,
'canReply' => $this->actor->can('reply', $model),
'canRename' => $this->actor->can('rename', $model),
'canDelete' => $this->actor->can('delete', $model),
'canHide' => $this->actor->can('hide', $model)
];
if ($model->hidden_at) {
$attributes['isHidden'] = true;
$attributes['hiddenAt'] = $this->formatDate($model->hidden_at);
}
Discussion::setStateUser($this->actor);
if ($state = $model->state) {
$attributes += [
'lastReadAt' => $this->formatDate($state->last_read_at),
'lastReadPostNumber' => (int) $state->last_read_post_number
];
}
return $attributes;
}
}

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\Api\Serializer;
use Flarum\Extension\Extension;
class ExtensionReadmeSerializer extends AbstractSerializer
{
/**
* @param Extension $model
*/
protected function getDefaultAttributes(object|array $model): array
{
return [
'content' => $model->getReadme()
];
}
public function getId($extension)
{
return $extension->getId();
}
public function getType($extension)
{
return 'extension-readmes';
}
}

View File

@@ -1,133 +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\Api\Serializer;
use Flarum\Foundation\Application;
use Flarum\Foundation\Config;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Cloud;
use Illuminate\Contracts\Filesystem\Factory;
use Tobscure\JsonApi\Relationship;
class ForumSerializer extends AbstractSerializer
{
protected $type = 'forums';
/**
* @var Config
*/
protected $config;
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var UrlGenerator
*/
protected $url;
/**
* @var Cloud
*/
protected $assetsFilesystem;
/**
* @param Config $config
* @param Factory $filesystemFactory
* @param SettingsRepositoryInterface $settings
* @param UrlGenerator $url
*/
public function __construct(Config $config, Factory $filesystemFactory, SettingsRepositoryInterface $settings, UrlGenerator $url)
{
$this->config = $config;
$this->assetsFilesystem = $filesystemFactory->disk('flarum-assets');
$this->settings = $settings;
$this->url = $url;
}
public function getId($model)
{
return '1';
}
/**
* @param array $model
*/
protected function getDefaultAttributes(object|array $model): array
{
$attributes = [
'title' => $this->settings->get('forum_title'),
'description' => $this->settings->get('forum_description'),
'showLanguageSelector' => (bool) $this->settings->get('show_language_selector', true),
'baseUrl' => $url = $this->url->to('forum')->base(),
'basePath' => $path = parse_url($url, PHP_URL_PATH) ?: '',
'baseOrigin' => substr($url, 0, strlen($url) - strlen($path)),
'debug' => $this->config->inDebugMode(),
'apiUrl' => $this->url->to('api')->base(),
'welcomeTitle' => $this->settings->get('welcome_title'),
'welcomeMessage' => $this->settings->get('welcome_message'),
'themePrimaryColor' => $this->settings->get('theme_primary_color'),
'themeSecondaryColor' => $this->settings->get('theme_secondary_color'),
'logoUrl' => $this->getLogoUrl(),
'faviconUrl' => $this->getFaviconUrl(),
'headerHtml' => $this->settings->get('custom_header'),
'footerHtml' => $this->settings->get('custom_footer'),
'allowSignUp' => (bool) $this->settings->get('allow_sign_up'),
'defaultRoute' => $this->settings->get('default_route'),
'canViewForum' => $this->actor->can('viewForum'),
'canStartDiscussion' => $this->actor->can('startDiscussion'),
'canSearchUsers' => $this->actor->can('searchUsers'),
'canCreateAccessToken' => $this->actor->can('createAccessToken'),
'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'),
'canEditUserCredentials' => $this->actor->hasPermission('user.editCredentials'),
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'),
];
if ($this->actor->can('administrate')) {
$attributes['adminUrl'] = $this->url->to('admin')->base();
$attributes['version'] = Application::VERSION;
}
return $attributes;
}
protected function groups(array $model): ?Relationship
{
return $this->hasMany($model, GroupSerializer::class);
}
protected function getLogoUrl(): ?string
{
$logoPath = $this->settings->get('logo_path');
return $logoPath ? $this->getAssetUrl($logoPath) : null;
}
protected function getFaviconUrl(): ?string
{
$faviconPath = $this->settings->get('favicon_path');
return $faviconPath ? $this->getAssetUrl($faviconPath) : null;
}
public function getAssetUrl(string $assetPath): string
{
return $this->assetsFilesystem->url($assetPath);
}
protected function actor(array $model): ?Relationship
{
return $this->hasOne($model, CurrentUserSerializer::class);
}
}

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\Api\Serializer;
use Flarum\Group\Group;
use Flarum\Locale\TranslatorInterface;
use InvalidArgumentException;
class GroupSerializer extends AbstractSerializer
{
protected $type = 'groups';
public function __construct(
protected TranslatorInterface $translator
) {
}
/**
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Group)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.Group::class
);
}
return [
'nameSingular' => $this->translateGroupName($model->name_singular),
'namePlural' => $this->translateGroupName($model->name_plural),
'color' => $model->color,
'icon' => $model->icon,
'isHidden' => $model->is_hidden
];
}
private function translateGroupName(string $name): string
{
$translation = $this->translator->trans($key = 'core.group.'.strtolower($name));
if ($translation !== $key) {
return $translation;
}
return $name;
}
}

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\Api\Serializer;
use Flarum\Mail\DriverInterface;
use InvalidArgumentException;
class MailSettingsSerializer extends AbstractSerializer
{
protected $type = 'mail-settings';
/**
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes(object|array $model): array
{
return [
'fields' => array_map([$this, 'serializeDriver'], $model['drivers']),
'sending' => $model['sending'],
'errors' => $model['errors'],
];
}
private function serializeDriver(DriverInterface $driver): array
{
return $driver->availableSettings();
}
public function getId($model)
{
return 'global';
}
}

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\Api\Serializer;
use Flarum\Notification\Notification;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class NotificationSerializer extends AbstractSerializer
{
protected $type = 'notifications';
/**
* A map of notification types (key) to the serializer that should be used
* to output the notification's subject (value).
*/
protected static array $subjectSerializers = [];
/**
* @throws InvalidArgumentException
*/
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Notification)) {
throw new InvalidArgumentException(
$this::class.' can only serialize instances of '.Notification::class
);
}
return [
'contentType' => $model->type,
'content' => $model->data,
'createdAt' => $this->formatDate($model->created_at),
'isRead' => (bool) $model->read_at
];
}
protected function user(Notification $notification): ?Relationship
{
return $this->hasOne($notification, BasicUserSerializer::class);
}
protected function fromUser(Notification $notification): ?Relationship
{
return $this->hasOne($notification, BasicUserSerializer::class);
}
protected function subject(Notification $notification): ?Relationship
{
return $this->hasOne($notification, function (Notification $notification) {
return static::$subjectSerializers[$notification->type];
});
}
public static function setSubjectSerializer(string $type, string $serializer): void
{
static::$subjectSerializers[$type] = $serializer;
}
}

View File

@@ -1,77 +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\Api\Serializer;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Tobscure\JsonApi\Relationship;
class PostSerializer extends BasicPostSerializer
{
/**
* @param Post $model
*/
protected function getDefaultAttributes(object|array $model): array
{
$attributes = parent::getDefaultAttributes($model);
unset($attributes['content']);
$canEdit = $this->actor->can('edit', $model);
if ($model instanceof CommentPost) {
if ($canEdit) {
$attributes['content'] = $model->content;
}
if ($this->actor->can('viewIps', $model)) {
$attributes['ipAddress'] = $model->ip_address;
}
} else {
$attributes['content'] = $model->content;
}
if ($model->edited_at) {
$attributes['editedAt'] = $this->formatDate($model->edited_at);
}
if ($model->hidden_at) {
$attributes['isHidden'] = true;
$attributes['hiddenAt'] = $this->formatDate($model->hidden_at);
}
$attributes += [
'canEdit' => $canEdit,
'canDelete' => $this->actor->can('delete', $model),
'canHide' => $this->actor->can('hide', $model)
];
return $attributes;
}
protected function user(Post $post): ?Relationship
{
return $this->hasOne($post, UserSerializer::class);
}
protected function discussion(Post $post): ?Relationship
{
return $this->hasOne($post, BasicDiscussionSerializer::class);
}
protected function editedUser(Post $post): ?Relationship
{
return $this->hasOne($post, BasicUserSerializer::class);
}
protected function hiddenUser(Post $post): ?Relationship
{
return $this->hasOne($post, BasicUserSerializer::class);
}
}

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\Api\Serializer;
use Flarum\User\User;
class UserSerializer extends BasicUserSerializer
{
/**
* @param User $model
*/
protected function getDefaultAttributes(object|array $model): array
{
$attributes = parent::getDefaultAttributes($model);
$attributes += [
'joinTime' => $this->formatDate($model->joined_at),
'discussionCount' => (int) $model->discussion_count,
'commentCount' => (int) $model->comment_count,
'canEdit' => $this->actor->can('edit', $model),
'canEditCredentials' => $this->actor->can('editCredentials', $model),
'canEditGroups' => $this->actor->can('editGroups', $model),
'canDelete' => $this->actor->can('delete', $model),
];
if ($model->getPreference('discloseOnline') || $this->actor->can('viewLastSeenAt', $model)) {
$attributes += [
'lastSeenAt' => $this->formatDate($model->last_seen_at)
];
}
if ($attributes['canEditCredentials'] || $this->actor->id === $model->id) {
$attributes += [
'isEmailConfirmed' => (bool) $model->is_email_confirmed,
'email' => $model->email
];
}
return $attributes;
}
}

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\Api\Sort;
use Tobyz\JsonApiServer\Laravel\Sort\SortColumn as BaseSortColumn;
class SortColumn extends BaseSortColumn
{
protected array $alias = [
'asc' => null,
'desc' => null,
];
public function ascendingAlias(?string $alias): static
{
$this->alias['asc'] = $alias;
return $this;
}
public function descendingAlias(?string $alias): static
{
$this->alias['desc'] = $alias;
return $this;
}
public function sortMap(): array
{
$map = [];
foreach ($this->alias as $direction => $alias) {
if ($alias) {
$sort = ($direction === 'asc' ? '' : '-').$this->name;
$map[$alias] = $sort;
}
}
return $map;
}
}

View File

@@ -19,27 +19,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$route->toController(Controller\ShowForumController::class)
);
// List access tokens
$map->get(
'/access-tokens',
'access-tokens.index',
$route->toController(Controller\ListAccessTokensController::class)
);
// Create access token
$map->post(
'/access-tokens',
'access-tokens.create',
$route->toController(Controller\CreateAccessTokenController::class)
);
// Delete access token
$map->delete(
'/access-tokens/{id}',
'access-tokens.delete',
$route->toController(Controller\DeleteAccessTokenController::class)
);
// Create authentication token
$map->post(
'/token',
@@ -67,55 +46,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|--------------------------------------------------------------------------
*/
// List users
$map->get(
'/users',
'users.index',
$route->toController(Controller\ListUsersController::class)
);
// Register a user
$map->post(
'/users',
'users.create',
$route->toController(Controller\CreateUserController::class)
);
// Get a single user
$map->get(
'/users/{id}',
'users.show',
$route->toController(Controller\ShowUserController::class)
);
// Edit a user
$map->patch(
'/users/{id}',
'users.update',
$route->toController(Controller\UpdateUserController::class)
);
// Delete a user
$map->delete(
'/users/{id}',
'users.delete',
$route->toController(Controller\DeleteUserController::class)
);
// Upload avatar
$map->post(
'/users/{id}/avatar',
'users.avatar.upload',
$route->toController(Controller\UploadAvatarController::class)
);
// Remove avatar
$map->delete(
'/users/{id}/avatar',
'users.avatar.delete',
$route->toController(Controller\DeleteAvatarController::class)
);
// send confirmation email
$map->post(
'/users/{id}/send-confirmation',
@@ -129,13 +59,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|--------------------------------------------------------------------------
*/
// List notifications for the current user
$map->get(
'/notifications',
'notifications.index',
$route->toController(Controller\ListNotificationsController::class)
);
// Mark all notifications as read
$map->post(
'/notifications/read',
@@ -143,13 +66,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$route->toController(Controller\ReadAllNotificationsController::class)
);
// Mark a single notification as read
$map->patch(
'/notifications/{id}',
'notifications.update',
$route->toController(Controller\UpdateNotificationController::class)
);
// Delete all notifications for the current user.
$map->delete(
'/notifications',
@@ -157,129 +73,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$route->toController(Controller\DeleteAllNotificationsController::class)
);
/*
|--------------------------------------------------------------------------
| Discussions
|--------------------------------------------------------------------------
*/
// List discussions
$map->get(
'/discussions',
'discussions.index',
$route->toController(Controller\ListDiscussionsController::class)
);
// Create a discussion
$map->post(
'/discussions',
'discussions.create',
$route->toController(Controller\CreateDiscussionController::class)
);
// Show a single discussion
$map->get(
'/discussions/{id}',
'discussions.show',
$route->toController(Controller\ShowDiscussionController::class)
);
// Edit a discussion
$map->patch(
'/discussions/{id}',
'discussions.update',
$route->toController(Controller\UpdateDiscussionController::class)
);
// Delete a discussion
$map->delete(
'/discussions/{id}',
'discussions.delete',
$route->toController(Controller\DeleteDiscussionController::class)
);
/*
|--------------------------------------------------------------------------
| Posts
|--------------------------------------------------------------------------
*/
// List posts, usually for a discussion
$map->get(
'/posts',
'posts.index',
$route->toController(Controller\ListPostsController::class)
);
// Create a post
$map->post(
'/posts',
'posts.create',
$route->toController(Controller\CreatePostController::class)
);
// Show a single or multiple posts by ID
$map->get(
'/posts/{id}',
'posts.show',
$route->toController(Controller\ShowPostController::class)
);
// Edit a post
$map->patch(
'/posts/{id}',
'posts.update',
$route->toController(Controller\UpdatePostController::class)
);
// Delete a post
$map->delete(
'/posts/{id}',
'posts.delete',
$route->toController(Controller\DeletePostController::class)
);
/*
|--------------------------------------------------------------------------
| Groups
|--------------------------------------------------------------------------
*/
// List groups
$map->get(
'/groups',
'groups.index',
$route->toController(Controller\ListGroupsController::class)
);
// Create a group
$map->post(
'/groups',
'groups.create',
$route->toController(Controller\CreateGroupController::class)
);
// Show a single group
$map->get(
'/groups/{id}',
'groups.show',
$route->toController(Controller\ShowGroupController::class)
);
// Edit a group
$map->patch(
'/groups/{id}',
'groups.update',
$route->toController(Controller\UpdateGroupController::class)
);
// Delete a group
$map->delete(
'/groups/{id}',
'groups.delete',
$route->toController(Controller\DeleteGroupController::class)
);
/*
|--------------------------------------------------------------------------
| Administration
@@ -300,13 +93,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$route->toController(Controller\UninstallExtensionController::class)
);
// Get readme for an extension
$map->get(
'/extension-readmes/{name}',
'extension-readmes.show',
$route->toController(Controller\ShowExtensionReadmeController::class)
);
// Extension bisect
$map->post(
'/extension-bisect',

View File

@@ -11,10 +11,8 @@ namespace Flarum\Database;
use Flarum\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use LogicException;
/**
* Base model class, building on Eloquent.
@@ -46,11 +44,6 @@ abstract class AbstractModel extends Eloquent
*/
protected array $afterDeleteCallbacks = [];
/**
* @internal
*/
public static array $customRelations = [];
/**
* @internal
*/
@@ -118,47 +111,6 @@ abstract class AbstractModel extends Eloquent
return $casts;
}
/**
* Get an attribute from the model. If nothing is found, attempt to load
* a custom relation method with this key.
*/
public function getAttribute($key)
{
if (! is_null($value = parent::getAttribute($key))) {
return $value;
}
// If a custom relation with this key has been set up, then we will load
// and return results from the query and hydrate the relationship's
// value on the "relationships" array.
if (! $this->relationLoaded($key) && ($relation = $this->getCustomRelation($key))) {
if (! $relation instanceof Relation) {
throw new LogicException(
'Relationship method must return an object of type '.Relation::class
);
}
return $this->relations[$key] = $relation->getResults();
}
return null;
}
/**
* Get a custom relation object.
*/
protected function getCustomRelation(string $name): mixed
{
foreach (array_merge([static::class], class_parents($this)) as $class) {
$relation = Arr::get(static::$customRelations, $class.".$name");
if (! is_null($relation)) {
return $relation($this);
}
}
return null;
}
/**
* Register a callback to be run once after the model is saved.
*/
@@ -199,15 +151,6 @@ abstract class AbstractModel extends Eloquent
return $callbacks;
}
public function __call($method, $parameters)
{
if ($relation = $this->getCustomRelation($method)) {
return $relation;
}
return parent::__call($method, $parameters);
}
public function newModelQuery()
{
$query = parent::newModelQuery();

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\Discussion\Command;
use Flarum\User\User;
class DeleteDiscussion
{
public function __construct(
public int $discussionId,
public User $actor,
public array $data = []
) {
}
}

View File

@@ -1,46 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Command;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Discussion\Event\Deleting;
use Flarum\Foundation\DispatchEventsTrait;
use Illuminate\Contracts\Events\Dispatcher;
class DeleteDiscussionHandler
{
use DispatchEventsTrait;
public function __construct(
protected Dispatcher $events,
protected DiscussionRepository $discussions
) {
}
public function handle(DeleteDiscussion $command): Discussion
{
$actor = $command->actor;
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);
$actor->assertCan('delete', $discussion);
$this->events->dispatch(
new Deleting($discussion, $actor, $command->data)
);
$discussion->delete();
$this->dispatchEventsFor($discussion, $actor);
return $discussion;
}
}

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