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

Compare commits

..

17 Commits

Author SHA1 Message Date
Sami Mazouz
96b8b92d42 phpstan 2024-06-21 10:30:58 +01:00
Sami Mazouz
a03104d61d fix 2024-06-21 10:25:02 +01:00
StyleCI Bot
7504e31399 Apply fixes from StyleCI 2024-06-21 09:38:55 +01:00
Sami Mazouz
aa39d0c11b chore: adapt 2024-06-21 09:38:55 +01:00
Sami Mazouz
d9e5ab4f11 chore 2024-06-21 09:38:55 +01:00
Sami Mazouz
ac27cd03dd chore: custom Serializer 2024-06-21 09:38:55 +01:00
StyleCI Bot
a442aad3be Apply fixes from StyleCI 2024-06-21 09:38:55 +01:00
Sami Mazouz
51e2ab8502 chore: drop the need for a json-api-server fork 2024-06-21 09:38:55 +01:00
Sami Mazouz
a8777c6198 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
2024-06-21 09:36:32 +01:00
flarum-bot
10514709f1 Bundled output for commit eb6e599df1
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-06-21 06:28:22 +00:00
Sami Mazouz
eb6e599df1 feat: add support for SQLite (#3984)
* feat: add support for sqlite

* chore: add warning on install

* fix: ignore constraints before transaction begins

* chore: update workflow

* Apply fixes from StyleCI

* chore: generate sqlite dump and manually add foreign keys

* chore: fix actions

* chore: fix actions

* chore: fix actions

* chore: fix actions

* chore: fix actions

* chore: fix actions

* test: fix

* Apply fixes from StyleCI

* fix: sqlite with db prefix

* Apply fixes from StyleCI

* fix: statistics sqlite
2024-06-21 07:25:11 +01:00
flarum-bot
5ce1aeab47 Bundled output for commit 389d004ddc
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-06-16 15:55:43 +00:00
Ngô Quốc Đạt
389d004ddc feat: Add conditional rendering for email status in MailPage.tsx (#3997) 2024-06-16 17:52:38 +02:00
Ngô Quốc Đạt
72f89c0209 fix: setting key safe_mode_extensions not exists (#3992) 2024-05-18 18:14:04 +01:00
Davide Iadeluca
1e7eddb61e ci: allow custom actions runner to be defined (#3988) 2024-05-16 17:30:13 +01:00
flarum-bot
1302378141 Bundled output for commit 29ede5aa27
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-05-14 20:13:28 +00:00
Sami Mazouz
29ede5aa27 feat: JS Notification extender (#3974)
* feat: JS `Notification` extender

* fix
2024-05-14 21:10:07 +01:00
150 changed files with 3546 additions and 514 deletions

View File

@@ -23,3 +23,6 @@ indent_size = 2
[*.neon]
indent_style = tab
[{install,update}.php]
indent_size = 2

View File

@@ -44,7 +44,7 @@ on:
description: Versions of databases to test with. Should be array of strings encoded as JSON array
type: string
required: false
default: '["mysql:5.7", "mysql:8.0.30", "mysql:8.1.0", "mariadb"]'
default: '["mysql:5.7", "mysql:8.0.30", "mysql:8.1.0", "mariadb", "sqlite:3"]'
php_ini_values:
description: PHP ini values
@@ -52,6 +52,12 @@ on:
required: false
default: error_reporting=E_ALL
runner_type:
description: The type of runner to use for the jobs. This should be one of the types supported by the `runs-on` keyword.
type: string
required: false
default: 'ubuntu-latest'
secrets:
composer_auth:
description: The Composer auth tokens to use for private packages.
@@ -65,7 +71,7 @@ env:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ inputs.runner_type }}
strategy:
matrix:
@@ -79,32 +85,49 @@ jobs:
# Expands the matrix by naming DBs.
- service: 'mysql:5.7'
db: MySQL 5.7
driver: mysql
- service: 'mysql:8.0.30'
db: MySQL 8.0
driver: mysql
- service: mariadb
db: MariaDB
driver: mysql
- service: 'mysql:8.1.0'
db: MySQL 8.1
driver: mysql
- service: 'sqlite:3'
db: SQLite
driver: sqlite
# Include Database prefix tests with only one PHP version.
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:5.7'
db: MySQL 5.7
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.0.30'
db: MySQL 8.0
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: mariadb
db: MariaDB
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.1.0'
db: MySQL 8.1
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'sqlite:3'
db: SQLite
driver: sqlite
prefix: flarum_
prefixStr: (prefix)
@@ -112,10 +135,22 @@ jobs:
exclude:
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'mysql:8.0.30'
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: mariadb
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: mariadb
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.1.0'
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'mysql:8.1.0'
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'sqlite:3'
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'sqlite:3'
services:
mysql:
image: ${{ matrix.service }}
image: ${{ matrix.service != 'sqlite:3' && matrix.service || '' }}
ports:
- 13306:3306
@@ -138,6 +173,7 @@ jobs:
ini-values: ${{ matrix.php_ini_values }}
- name: Create MySQL Database
if: ${{ matrix.service != 'sqlite:3' }}
run: |
sudo systemctl start mysql
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
@@ -167,10 +203,11 @@ jobs:
DB_PORT: 13306
DB_PASSWORD: root
DB_PREFIX: ${{ matrix.prefix }}
DB_DRIVER: ${{ matrix.driver }}
COMPOSER_PROCESS_TIMEOUT: 600
phpstan:
runs-on: ubuntu-latest
runs-on: ${{ inputs.runner_type }}
strategy:
matrix:

View File

@@ -86,6 +86,12 @@ on:
type: string
required: false
runner_type:
description: The type of runner to use for the jobs. This should be one of the types supported by the `runs-on` keyword.
type: string
required: false
default: 'ubuntu-latest'
secrets:
bundlewatch_github_token:
description: The GitHub token to use for Bundlewatch.
@@ -103,7 +109,7 @@ env:
jobs:
build:
name: Checks & Build
runs-on: ubuntu-latest
runs-on: ${{ inputs.runner_type }}
if: >-
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')

View File

@@ -162,7 +162,7 @@
"symfony/postmark-mailer": "^6.3",
"symfony/translation": "^6.3",
"symfony/yaml": "^6.3",
"flarum/json-api-server": "^1.0.0",
"flarum/json-api-server": "^0.1.0",
"wikimedia/less.php": "^4.1"
},
"require-dev": {

View File

@@ -14,6 +14,7 @@ use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Post\Post;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -30,6 +31,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Flag extends AbstractModel
{
use ScopeVisibilityTrait;
use HasFactory;
public $timestamps = true;

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Flags;
use Carbon\Carbon;
use Flarum\Post\Post;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class FlagFactory extends Factory
{
public function definition(): array
{
return [
'type' => 'user',
'post_id' => Post::factory(),
'user_id' => User::factory(),
'reason' => $this->faker->sentence,
'reason_detail' => $this->faker->sentence,
'created_at' => Carbon::now(),
];
}
}

View File

@@ -10,6 +10,7 @@
namespace Flarum\Flags\Tests\integration\api\flags;
use Flarum\Discussion\Discussion;
use Flarum\Flags\Flag;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
@@ -56,7 +57,7 @@ class ListTest extends TestCase
['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 4, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>', 'is_private' => true],
],
'flags' => [
Flag::class => [
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 2],
['id' => 3, 'post_id' => 1, 'user_id' => 3],

View File

@@ -10,6 +10,7 @@
namespace Flarum\Flags\Tests\integration\api\flags;
use Flarum\Discussion\Discussion;
use Flarum\Flags\Flag;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Tags\Tag;
@@ -83,7 +84,7 @@ class ListWithTagsTest extends TestCase
['id' => 6, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 7, 'discussion_id' => 5, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
],
'flags' => [
Flag::class => [
// From regular ListTest
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 2],

View File

@@ -45,13 +45,13 @@ return [
->fields(PostResourceFields::class)
->endpoint(
[Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class, Endpoint\Update::class],
function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\EndpointInterface {
function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\Endpoint {
return $endpoint->addDefaultInclude(['likes']);
}
),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\EndpointInterface {
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Endpoint {
return $endpoint->addDefaultInclude(['posts.likes']);
}),

2
extensions/likes/js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,11 +2,15 @@ import Extend from 'flarum/common/extenders';
import Post from 'flarum/common/models/Post';
import User from 'flarum/common/models/User';
import LikesUserPage from './components/LikesUserPage';
import PostLikedNotification from './components/PostLikedNotification';
export default [
new Extend.Routes() //
.add('user.likes', '/u/:username/likes', LikesUserPage),
new Extend.Notification() //
.add('postLiked', PostLikedNotification),
new Extend.Model(Post) //
.hasMany<User>('likes')
.attribute<number>('likesCount')

View File

@@ -3,14 +3,11 @@ import app from 'flarum/forum/app';
import addLikeAction from './addLikeAction';
import addLikesList from './addLikesList';
import PostLikedNotification from './components/PostLikedNotification';
import addLikesTabToUserProfile from './addLikesTabToUserProfile';
export { default as extend } from './extend';
app.initializers.add('flarum-likes', () => {
app.notificationComponents.postLiked = PostLikedNotification;
addLikeAction();
addLikesList();
addLikesTabToUserProfile();

View File

@@ -51,7 +51,7 @@ class PostResourceFields
Schema\Relationship\ToMany::make('likes')
->type('users')
->includable()
->constrain(function (Builder $query, Context $context) {
->scope(function (Builder $query, Context $context) {
$actor = $context->getActor();
$grammar = $query->getQuery()->getGrammar();

2
extensions/lock/js/dist/forum.js generated vendored
View File

@@ -1,2 +1,2 @@
(()=>{var o={n:e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},d:(e,t)=>{for(var n in t)o.o(t,n)&&!o.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},o:(o,e)=>Object.prototype.hasOwnProperty.call(o,e),r:o=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(o,"__esModule",{value:!0})}},e={};(()=>{"use strict";o.r(e),o.d(e,{extend:()=>D});const t=flarum.reg.get("core","common/extend"),n=flarum.reg.get("core","forum/app");var r=o.n(n);const s=flarum.reg.get("core","forum/components/Notification");var c=o.n(s);class a extends(c()){icon(){return"fas fa-lock"}href(){const o=this.attrs.notification;return r().route.discussion(o.subject(),o.content().postNumber)}content(){return r().translator.trans("flarum-lock.forum.notifications.discussion_locked_text",{user:this.attrs.notification.fromUser()})}excerpt(){return null}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedNotification",a);const i=flarum.reg.get("core","common/models/Discussion");var l=o.n(i);const u=flarum.reg.get("core","common/components/Badge");var d=o.n(u);const f=flarum.reg.get("core","forum/utils/DiscussionControls");var k=o.n(f);const g=flarum.reg.get("core","forum/components/DiscussionPage");var p=o.n(g);const b=flarum.reg.get("core","common/components/Button");var y=o.n(b);const _=flarum.reg.get("core","common/extenders");var v=o.n(_);const x=flarum.reg.get("core","forum/components/EventPost");var L=o.n(x);class h extends(L()){icon(){return this.attrs.post.content().locked?"fas fa-lock":"fas fa-unlock"}descriptionKey(){return this.attrs.post.content().locked?"flarum-lock.forum.post_stream.discussion_locked_text":"flarum-lock.forum.post_stream.discussion_unlocked_text"}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedPost",h);const P=flarum.reg.get("core","common/query/IGambit"),S=flarum.reg.get("core","common/app");var j=o.n(S);class w extends P.BooleanGambit{key(){return j().translator.trans("flarum-lock.lib.gambits.discussions.locked.key",{},!0)}filterKey(){return"locked"}}flarum.reg.add("flarum-lock","common/query/discussions/LockedGambit",w);const D=[(new(v().Search)).gambit("discussions",w),(new(v().PostTypes)).add("discussionLocked",h),new(v().Model)(l()).attribute("isLocked").attribute("canLock")];r().initializers.add("flarum-lock",(()=>{r().notificationComponents.discussionLocked=a,(0,t.extend)(l().prototype,"badges",(function(o){this.isLocked()&&o.add("locked",m(d(),{type:"locked",label:r().translator.trans("flarum-lock.forum.badge.locked_tooltip"),icon:"fas fa-lock"}))})),(0,t.extend)(k(),"moderationControls",(function(o,e){e.canLock()&&o.add("lock",m(y(),{icon:"fas fa-lock",onclick:this.lockAction.bind(e)},r().translator.trans("flarum-lock.forum.discussion_controls.".concat(e.isLocked()?"unlock":"lock","_button"))))})),k().lockAction=function(){this.save({isLocked:!this.isLocked()}).then((()=>{r().current.matches(p())&&r().current.get("stream").update(),m.redraw()}))},(0,t.extend)("flarum/forum/components/NotificationGrid","notificationTypes",(function(o){o.add("discussionLocked",{name:"discussionLocked",icon:"fas fa-lock",label:r().translator.trans("flarum-lock.forum.settings.notify_discussion_locked_label")})}))}))})(),module.exports=e})();
(()=>{var o={n:e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},d:(e,t)=>{for(var n in t)o.o(t,n)&&!o.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},o:(o,e)=>Object.prototype.hasOwnProperty.call(o,e),r:o=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(o,"__esModule",{value:!0})}},e={};(()=>{"use strict";o.r(e),o.d(e,{extend:()=>N});const t=flarum.reg.get("core","common/extend"),n=flarum.reg.get("core","forum/app");var r=o.n(n);const s=flarum.reg.get("core","common/models/Discussion");var c=o.n(s);const a=flarum.reg.get("core","common/components/Badge");var i=o.n(a);const l=flarum.reg.get("core","forum/utils/DiscussionControls");var u=o.n(l);const d=flarum.reg.get("core","forum/components/DiscussionPage");var f=o.n(d);const k=flarum.reg.get("core","common/components/Button");var g=o.n(k);const p=flarum.reg.get("core","common/extenders");var b=o.n(p);const y=flarum.reg.get("core","forum/components/EventPost");var _=o.n(y);class v extends(_()){icon(){return this.attrs.post.content().locked?"fas fa-lock":"fas fa-unlock"}descriptionKey(){return this.attrs.post.content().locked?"flarum-lock.forum.post_stream.discussion_locked_text":"flarum-lock.forum.post_stream.discussion_unlocked_text"}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedPost",v);const x=flarum.reg.get("core","common/query/IGambit"),L=flarum.reg.get("core","common/app");var h=o.n(L);class P extends x.BooleanGambit{key(){return h().translator.trans("flarum-lock.lib.gambits.discussions.locked.key",{},!0)}filterKey(){return"locked"}}flarum.reg.add("flarum-lock","common/query/discussions/LockedGambit",P);const w=[(new(b().Search)).gambit("discussions",P)],S=flarum.reg.get("core","forum/components/Notification");var j=o.n(S);class D extends(j()){icon(){return"fas fa-lock"}href(){const o=this.attrs.notification;return r().route.discussion(o.subject(),o.content().postNumber)}content(){return r().translator.trans("flarum-lock.forum.notifications.discussion_locked_text",{user:this.attrs.notification.fromUser()})}excerpt(){return null}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedNotification",D);const N=[...w,(new(b().PostTypes)).add("discussionLocked",v),(new(b().Notification)).add("discussionLocked",D),new(b().Model)(c()).attribute("isLocked").attribute("canLock")];r().initializers.add("flarum-lock",(()=>{(0,t.extend)(c().prototype,"badges",(function(o){this.isLocked()&&o.add("locked",m(i(),{type:"locked",label:r().translator.trans("flarum-lock.forum.badge.locked_tooltip"),icon:"fas fa-lock"}))})),(0,t.extend)(u(),"moderationControls",(function(o,e){e.canLock()&&o.add("lock",m(g(),{icon:"fas fa-lock",onclick:this.lockAction.bind(e)},r().translator.trans("flarum-lock.forum.discussion_controls.".concat(e.isLocked()?"unlock":"lock","_button"))))})),u().lockAction=function(){this.save({isLocked:!this.isLocked()}).then((()=>{r().current.matches(f())&&r().current.get("stream").update(),m.redraw()}))},(0,t.extend)("flarum/forum/components/NotificationGrid","notificationTypes",(function(o){o.add("discussionLocked",{name:"discussionLocked",icon:"fas fa-lock",label:r().translator.trans("flarum-lock.forum.settings.notify_discussion_locked_label")})}))}))})(),module.exports=e})();
//# sourceMappingURL=forum.js.map

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@ import Discussion from 'flarum/common/models/Discussion';
import DiscussionLockedPost from './components/DiscussionLockedPost';
import commonExtend from '../common/extend';
import DiscussionLockedNotification from './components/DiscussionLockedNotification';
export default [
...commonExtend,
@@ -10,6 +11,9 @@ export default [
new Extend.PostTypes() //
.add('discussionLocked', DiscussionLockedPost),
new Extend.Notification() //
.add('discussionLocked', DiscussionLockedNotification),
new Extend.Model(Discussion) //
.attribute<boolean>('isLocked')
.attribute<boolean>('canLock'),

View File

@@ -1,15 +1,12 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import DiscussionLockedNotification from './components/DiscussionLockedNotification';
import addLockBadge from './addLockBadge';
import addLockControl from './addLockControl';
export { default as extend } from './extend';
app.initializers.add('flarum-lock', () => {
app.notificationComponents.discussionLocked = DiscussionLockedNotification;
addLockBadge();
addLockControl();

View File

@@ -63,7 +63,7 @@ return [
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class)
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface {
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\Endpoint {
return $endpoint->addDefaultInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']);
})
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
@@ -137,7 +137,7 @@ return [
}),
(new Extend\ApiResource(Resource\PostResource::class))
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface {
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\Endpoint {
return $endpoint->eagerLoad(['mentionsTags']);
}),
]),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,9 @@ import Extend from 'flarum/common/extenders';
import Post from 'flarum/common/models/Post';
import User from 'flarum/common/models/User';
import MentionsUserPage from './components/MentionsUserPage';
import PostMentionedNotification from './components/PostMentionedNotification';
import UserMentionedNotification from './components/UserMentionedNotification';
import GroupMentionedNotification from './components/GroupMentionedNotification';
export default [
new Extend.Routes() //
@@ -11,6 +14,11 @@ export default [
.hasMany<Post>('mentionedBy')
.attribute<number>('mentionedByCount'),
new Extend.Notification() //
.add('postMentioned', PostMentionedNotification)
.add('userMentioned', UserMentionedNotification)
.add('groupMentioned', GroupMentionedNotification),
new Extend.Model(User) //
.attribute<boolean>('canMentionGroups'),
];

View File

@@ -9,9 +9,6 @@ import addMentionedByList from './addMentionedByList';
import addPostReplyAction from './addPostReplyAction';
import addPostQuoteButton from './addPostQuoteButton';
import addComposerAutocomplete from './addComposerAutocomplete';
import PostMentionedNotification from './components/PostMentionedNotification';
import UserMentionedNotification from './components/UserMentionedNotification';
import GroupMentionedNotification from './components/GroupMentionedNotification';
import MentionFormats from './mentionables/formats/MentionFormats';
import UserPage from 'flarum/forum/components/UserPage';
import LinkButton from 'flarum/common/components/LinkButton';
@@ -40,10 +37,6 @@ app.initializers.add('flarum-mentions', function () {
// posts or users that the user could mention.
addComposerAutocomplete();
app.notificationComponents.postMentioned = PostMentionedNotification;
app.notificationComponents.userMentioned = UserMentionedNotification;
app.notificationComponents.groupMentioned = GroupMentionedNotification;
// Add notification preferences.
extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) {
items.add('postMentioned', {

View File

@@ -16,10 +16,9 @@ return [
$table->timestamp('created_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `{$prefix}post_mentions_post` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$schema->table('post_mentions_post', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
});
},
'down' => function (Builder $schema) {

View File

@@ -16,10 +16,9 @@ return [
$table->timestamp('created_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `{$prefix}post_mentions_user` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$schema->table('post_mentions_user', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
});
},
'down' => function (Builder $schema) {

View File

@@ -25,7 +25,7 @@ class PostResourceFields
Schema\Relationship\ToMany::make('mentionedBy')
->type('posts')
->includable()
->constrain(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)),
->scope(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)),
Schema\Relationship\ToMany::make('mentionsPosts')
->type('posts'),
Schema\Relationship\ToMany::make('mentionsUsers')

View File

@@ -130,12 +130,16 @@ class ShowStatisticsData implements RequestHandlerInterface
$endDate = new DateTime();
}
// if within the last 24 hours, group by hour
$format = 'CASE WHEN '.$column.' > ? THEN \'%Y-%m-%d %H:00:00\' ELSE \'%Y-%m-%d\' END';
$dbFormattedDatetime = match ($query->getConnection()->getDriverName()) {
'sqlite' => 'strftime('.$format.', '.$column.')',
default => 'DATE_FORMAT('.$column.', '.$format.')',
};
$results = $query
->selectRaw(
'DATE_FORMAT(
@date := '.$column.',
IF(@date > ?, \'%Y-%m-%d %H:00:00\', \'%Y-%m-%d\') -- if within the last 24 hours, group by hour
) as time_group',
$dbFormattedDatetime.' as time_group',
[new DateTime('-25 hours')]
)
->selectRaw('COUNT(id) as count')

View File

@@ -102,7 +102,7 @@ class CanRequestCustomTimedStatisticsTest extends TestCase
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body['errors'][0]['detail'] ?? '');
$this->assertEquals(
$data,

View File

@@ -10,7 +10,6 @@
namespace Flarum\Sticky\Api;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Update;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Sticky\Event\DiscussionWasStickied;
@@ -23,7 +22,7 @@ class DiscussionResourceFields
return [
Schema\Boolean::make('isSticky')
->writable(function (Discussion $discussion, Context $context) {
return $context->endpoint instanceof Update
return $context->updating()
&& $context->getActor()->can('sticky', $discussion);
})
->set(function (Discussion $discussion, bool $isSticky, Context $context) {

View File

@@ -12,6 +12,7 @@ namespace Flarum\Sticky;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\SearchCriteria;
use Flarum\Tags\Search\Filter\TagFilter;
use Illuminate\Database\Query\Builder;
class PinStickiedDiscussionsToTop
{
@@ -45,22 +46,26 @@ class PinStickiedDiscussionsToTop
$sticky->where('is_sticky', true);
unset($sticky->orders);
/** @var Builder $q */
foreach ([$sticky, $query] as $q) {
$read = $q->newQuery()
->selectRaw('1')
->from('discussion_user as sticky')
->whereColumn('sticky.discussion_id', 'id')
->where('sticky.user_id', '=', $state->getActor()->id)
->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number');
// Add the bindings manually (rather than as the second
// argument in orderByRaw) for now due to a bug in Laravel which
// would add the bindings in the wrong order.
$q->selectRaw('(is_sticky and not exists ('.$read->toSql().') and last_posted_at > ?) as is_unread_sticky', array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0]));
}
$query->union($sticky);
$read = $query->newQuery()
->selectRaw('1')
->from('discussion_user as sticky')
->whereColumn('sticky.discussion_id', 'id')
->where('sticky.user_id', '=', $state->getActor()->id)
->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number');
$query->orderByDesc('is_unread_sticky');
// Add the bindings manually (rather than as the second
// argument in orderByRaw) for now due to a bug in Laravel which
// would add the bindings in the wrong order.
$query->orderByRaw('is_sticky and not exists ('.$read->toSql().') and last_posted_at > ? desc')
->addBinding(array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0]), 'union');
$query->unionOrders = array_merge($query->unionOrders, $query->orders ?? []);
$query->unionOrders = array_merge($query->unionOrders ?? [], $query->orders ?? []);
$query->unionLimit = $query->limit;
$query->unionOffset = $query->offset;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@ import IndexPage from 'flarum/forum/components/IndexPage';
import Discussion from 'flarum/common/models/Discussion';
import commonExtend from '../common/extend';
import NewPostNotification from './components/NewPostNotification';
export default [
...commonExtend,
@@ -10,6 +11,9 @@ export default [
new Extend.Routes() //
.add('following', '/following', IndexPage),
new Extend.Notification() //
.add('newPost', NewPostNotification),
new Extend.Model(Discussion) //
.attribute('subscription'),
];

View File

@@ -6,13 +6,9 @@ import addSubscriptionControls from './addSubscriptionControls';
import addSubscriptionFilter from './addSubscriptionFilter';
import addSubscriptionSettings from './addSubscriptionSettings';
import NewPostNotification from './components/NewPostNotification';
export { default as extend } from './extend';
app.initializers.add('subscriptions', function () {
app.notificationComponents.newPost = NewPostNotification;
addSubscriptionBadge();
addSubscriptionControls();
addSubscriptionFilter();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,16 @@ import User from 'flarum/common/models/User';
import Model from 'flarum/common/Model';
import commonExtend from '../common/extend';
import UserSuspendedNotification from './components/UserSuspendedNotification';
import UserUnsuspendedNotification from './components/UserUnsuspendedNotification';
export default [
...commonExtend,
new Extend.Notification() //
.add('userSuspended', UserSuspendedNotification)
.add('userUnsuspended', UserUnsuspendedNotification),
new Extend.Model(User)
.attribute<Date | null | undefined, string | null | undefined>('suspendedUntil', Model.transformDate)
.attribute<string | null | undefined>('suspendReason')

View File

@@ -6,16 +6,11 @@ import Badge from 'flarum/common/components/Badge';
import User from 'flarum/common/models/User';
import SuspendUserModal from './components/SuspendUserModal';
import UserSuspendedNotification from './components/UserSuspendedNotification';
import UserUnsuspendedNotification from './components/UserUnsuspendedNotification';
import checkForSuspension from './checkForSuspension';
export { default as extend } from './extend';
app.initializers.add('flarum-suspend', () => {
app.notificationComponents.userSuspended = UserSuspendedNotification;
app.notificationComponents.userUnsuspended = UserUnsuspendedNotification;
extend(UserControls, 'moderationControls', (items, user) => {
if (user.canSuspend()) {
items.add(

View File

@@ -14,9 +14,14 @@ return [
'up' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('discussions_count', 'discussion_count');
});
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('last_time', 'last_posted_at');
});
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('last_discussion_id', 'last_posted_discussion_id');
});
$schema->table('tags', function (Blueprint $table) {
$table->integer('parent_id')->unsigned()->nullable()->change();
$table->integer('last_posted_user_id')->unsigned()->nullable();
@@ -26,11 +31,17 @@ return [
'down' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->dropColumn('last_posted_user_id');
});
$schema->table('tags', function (Blueprint $table) {
$table->integer('parent_id')->nullable()->change();
});
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('discussion_count', 'discussions_count');
});
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('last_posted_at', 'last_time');
});
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('last_posted_discussion_id', 'last_discussion_id');
});
}

View File

@@ -17,17 +17,15 @@ return [
$table->timestamp('updated_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `{$prefix}tags` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$connection->statement("ALTER TABLE `{$prefix}tags` MODIFY updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP");
$schema->table('tags', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
$table->timestamp('updated_at')->nullable()->useCurrent()->useCurrentOnUpdate()->change();
});
},
'down' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->dropColumn('created_at');
$table->dropColumn('updated_at');
$table->dropColumn('created_at', 'updated_at');
});
}
];

View File

@@ -16,10 +16,9 @@ return [
$table->timestamp('created_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `{$prefix}discussion_tag` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$schema->table('discussion_tag', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
});
},
'down' => function (Builder $schema) {

View File

@@ -91,7 +91,7 @@
"symfony/translation": "^6.3",
"symfony/translation-contracts": "^2.5",
"symfony/yaml": "^6.3",
"flarum/json-api-server": "^1.0.0",
"flarum/json-api-server": "^0.1.0",
"wikimedia/less.php": "^4.1"
},
"require-dev": {

View File

@@ -0,0 +1,14 @@
import IExtender, { IExtensionModule } from './IExtender';
import type Application from '../Application';
import type { NewComponent } from '../Application';
export default class Notification implements IExtender {
private notificationComponents;
/**
* Register a new notification component type.
*
* @param name The name of the notification type.
* @param component The component class to render the notification.
*/
add(name: string, component: NewComponent<any>): Notification;
extend(app: Application, extension: IExtensionModule): void;
}

View File

@@ -3,11 +3,13 @@ import PostTypes from './PostTypes';
import Routes from './Routes';
import Store from './Store';
import Search from './Search';
import Notification from './Notification';
declare const extenders: {
Model: typeof Model;
PostTypes: typeof PostTypes;
Routes: typeof Routes;
Store: typeof Store;
Search: typeof Search;
Notification: typeof Notification;
};
export default extenders;

2
framework/core/js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
framework/core/js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -96,7 +96,9 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
const fields = this.driverFields![this.setting('mail_driver')()];
const fieldKeys = Object.keys(fields);
items.add('status', this.status!.sending || <Alert dismissible={false}>{app.translator.trans('core.admin.email.not_sending_message')}</Alert>);
if (this.status!.sending) {
items.add('status', <Alert dismissible={false}>{app.translator.trans('core.admin.email.not_sending_message')}</Alert>);
}
items.add(
'mail_from',

View File

@@ -34,7 +34,7 @@ export default class StatusWidget extends DashboardWidget {
items.add('version-flarum', [<strong>Flarum</strong>, <br />, app.forum.attribute('version')], 100);
items.add('version-php', [<strong>PHP</strong>, <br />, app.data.phpVersion], 90);
items.add('version-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion], 80);
items.add('version-db', [<strong>{app.data.dbDriver}</strong>, <br />, app.data.dbVersion], 80);
if (app.data.schedulerStatus) {
items.add(
'schedule-status',

View File

@@ -0,0 +1,25 @@
import IExtender, { IExtensionModule } from './IExtender';
import type Component from '../Component';
import ForumApplication from '../../forum/ForumApplication';
import type Application from '../Application';
import type { NewComponent } from '../Application';
export default class Notification implements IExtender {
private notificationComponents: Record<string, new () => Component> = {};
/**
* Register a new notification component type.
*
* @param name The name of the notification type.
* @param component The component class to render the notification.
*/
add(name: string, component: NewComponent<any>): Notification {
this.notificationComponents[name] = component;
return this;
}
extend(app: Application, extension: IExtensionModule): void {
Object.assign((app as unknown as ForumApplication).notificationComponents, this.notificationComponents);
}
}

View File

@@ -3,6 +3,7 @@ import PostTypes from './PostTypes';
import Routes from './Routes';
import Store from './Store';
import Search from './Search';
import Notification from './Notification';
const extenders = {
Model,
@@ -10,6 +11,7 @@ const extenders = {
Routes,
Store,
Search,
Notification,
};
export default extenders;

View File

@@ -34,7 +34,10 @@ return [
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement('ALTER TABLE '.$prefix.'posts ADD FULLTEXT content (content)');
if ($connection->getDriverName() !== 'sqlite') {
$connection->statement('ALTER TABLE '.$prefix.'posts ADD FULLTEXT content (content)');
}
},
'down' => function (Builder $schema) {

View File

@@ -12,10 +12,12 @@ use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$table->dropColumn('created_at', 'expires_at');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->string('id', 40)->change();
$table->dropColumn('created_at');
$table->dropColumn('expires_at');
$table->integer('last_activity');
$table->integer('lifetime');
});
@@ -24,8 +26,7 @@ return [
'down' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$table->string('id', 100)->change();
$table->dropColumn('last_activity');
$table->dropColumn('lifetime');
$table->dropColumn('last_activity', 'lifetime');
$table->timestamp('created_at');
$table->timestamp('expires_at');
});

View File

@@ -14,14 +14,20 @@ return [
'up' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$table->renameColumn('id', 'token');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->renameColumn('lifetime', 'lifetime_seconds');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->renameColumn('last_activity', 'last_activity_at');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->dateTime('created_at');
$table->integer('user_id')->unsigned()->change();
});
// Use a separate schema instance because this column gets renamed
// in the first one.
// in the previous one.
$schema->table('access_tokens', function (Blueprint $table) {
$table->dateTime('last_activity_at')->change();
});
@@ -31,12 +37,19 @@ return [
$schema->table('access_tokens', function (Blueprint $table) {
$table->integer('last_activity_at')->change();
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->renameColumn('token', 'id');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->renameColumn('lifetime_seconds', 'lifetime');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->renameColumn('last_activity_at', 'last_activity');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->dropColumn('created_at');
});
$schema->table('access_tokens', function (Blueprint $table) {
$table->integer('user_id')->change();
});
}

View File

@@ -12,13 +12,7 @@ use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('api_keys', function (Blueprint $table) {
$table->dropPrimary(['id']);
$table->renameColumn('id', 'key');
$table->unique('key');
});
$schema->table('api_keys', function (Blueprint $table) {
$definition = function (Blueprint $table) {
$table->increments('id');
$table->string('allowed_ips')->nullable();
$table->string('scopes')->nullable();
@@ -27,7 +21,23 @@ return [
$table->dateTime('last_activity_at')->nullable();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
};
if ($schema->getConnection()->getDriverName() !== 'sqlite') {
$schema->table('api_keys', function (Blueprint $table) {
$table->dropPrimary(['id']);
$table->renameColumn('id', 'key');
$table->unique('key');
});
$schema->table('api_keys', $definition);
} else {
$schema->drop('api_keys');
$schema->create('api_keys', function (Blueprint $table) use ($definition) {
$table->string('key', 100)->unique();
$definition($table);
});
}
},
'down' => function (Builder $schema) {
@@ -36,10 +46,13 @@ return [
$table->dropColumn('id', 'allowed_ips', 'user_id', 'scopes', 'created_at');
});
$schema->table('api_keys', function (Blueprint $table) {
$schema->table('api_keys', function (Blueprint $table) use ($schema) {
$table->dropUnique(['key']);
$table->renameColumn('key', 'id');
$table->primary('id');
if ($schema->getConnection()->getDriverName() !== 'sqlite') {
$table->primary('id');
}
});
}
];

View File

@@ -7,19 +7,19 @@
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE {$prefix}registration_tokens MODIFY created_at DATETIME");
$schema->table('registration_tokens', function (Blueprint $table) {
$table->dateTime('created_at')->change();
});
},
'down' => function (Builder $schema) {
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE {$prefix}registration_tokens MODIFY created_at TIMESTAMP");
$schema->table('registration_tokens', function (Blueprint $table) {
$table->timestamp('created_at')->change();
});
}
];

View File

@@ -12,13 +12,19 @@ use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = InnoDB');
if ($connection->getDriverName() === 'mysql') {
$prefix = $connection->getTablePrefix();
$connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = InnoDB');
}
},
'down' => function (Builder $schema) {
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = MyISAM');
if ($connection->getDriverName() === 'mysql') {
$prefix = $connection->getTablePrefix();
$connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = MyISAM');
}
}
];

View File

@@ -7,19 +7,19 @@
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE {$prefix}email_tokens MODIFY created_at DATETIME");
$schema->table('email_tokens', function (Blueprint $table) {
$table->dateTime('created_at')->change();
});
},
'down' => function (Builder $schema) {
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE {$prefix}email_tokens MODIFY created_at TIMESTAMP");
$schema->table('email_tokens', function (Blueprint $table) {
$table->timestamp('created_at')->change();
});
}
];

View File

@@ -15,10 +15,14 @@ return [
'up' => function (Builder $schema) {
$schema->table('notifications', function (Blueprint $table) {
$table->dropColumn('subject_type');
});
$schema->table('notifications', function (Blueprint $table) {
$table->renameColumn('time', 'created_at');
});
$schema->table('notifications', function (Blueprint $table) {
$table->renameColumn('sender_id', 'from_user_id');
});
$schema->table('notifications', function (Blueprint $table) {
$table->dateTime('read_at')->nullable();
});
@@ -36,8 +40,11 @@ return [
$table->string('subject_type', 200)->nullable();
$table->renameColumn('created_at', 'time');
});
$schema->table('notifications', function (Blueprint $table) {
$table->renameColumn('from_user_id', 'sender_id');
});
$schema->table('notifications', function (Blueprint $table) {
$table->boolean('is_read');
});

View File

@@ -7,19 +7,19 @@
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE {$prefix}password_tokens MODIFY created_at DATETIME");
$schema->table('password_tokens', function (Blueprint $table) {
$table->dateTime('created_at')->change();
});
},
'down' => function (Builder $schema) {
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE {$prefix}password_tokens MODIFY created_at TIMESTAMP");
$schema->table('password_tokens', function (Blueprint $table) {
$table->timestamp('created_at')->change();
});
}
];

View File

@@ -13,7 +13,10 @@ return [
'up' => function (Builder $schema) {
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement('ALTER TABLE '.$prefix.'discussions ADD FULLTEXT title (title)');
if ($connection->getDriverName() !== 'sqlite') {
$connection->statement('ALTER TABLE '.$prefix.'discussions ADD FULLTEXT title (title)');
}
},
'down' => function (Builder $schema) {

View File

@@ -12,24 +12,42 @@ use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
// Replace primary key with unique index so we can create a new primary
$table->dropPrimary('token');
$table->unique('token');
});
if ($schema->getConnection()->getDriverName() !== 'sqlite') {
$schema->table('access_tokens', function (Blueprint $table) {
// Replace primary key with unique index so we can create a new primary
$table->dropPrimary('token');
$table->unique('token');
});
// This needs to be done in a second statement because of the order Laravel runs operations in
$schema->table('access_tokens', function (Blueprint $table) {
// Introduce new increment-based ID
$table->increments('id')->first();
});
// This needs to be done in a second statement because of the order Laravel runs operations in
$schema->table('access_tokens', function (Blueprint $table) {
// Introduce new increment-based ID
$table->increments('id')->first();
});
} else {
$schema->drop('access_tokens');
$schema->create('access_tokens', function (Blueprint $table) {
$table->increments('id');
$table->string('token', 100)->unique();
$table->integer('user_id')->unsigned();
$table->dateTime('last_activity_at')->nullable();
$table->dateTime('created_at');
$table->string('type', 100)->nullable();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->index('type');
});
}
},
'down' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$schema->table('access_tokens', function (Blueprint $table) use ($schema) {
$table->dropColumn('id');
$table->dropIndex('token');
$table->primary('token');
if ($schema->getConnection()->getDriverName() !== 'sqlite') {
$table->primary('token');
}
});
}
];

View File

@@ -17,17 +17,15 @@ return [
$table->timestamp('updated_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `${prefix}groups` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$connection->statement("ALTER TABLE `${prefix}groups` MODIFY updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP");
$schema->table('groups', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate()->change();
});
},
'down' => function (Builder $schema) {
$schema->table('groups', function (Blueprint $table) {
$table->dropColumn('created_at');
$table->dropColumn('updated_at');
$table->dropColumn('created_at', 'updated_at');
});
}
];

View File

@@ -16,10 +16,9 @@ return [
$table->timestamp('created_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `${prefix}group_user` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$schema->table('group_user', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
});
},
'down' => function (Builder $schema) {

View File

@@ -16,10 +16,9 @@ return [
$table->timestamp('created_at')->nullable();
});
// do this manually because dbal doesn't recognize timestamp columns
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
$connection->statement("ALTER TABLE `${prefix}group_permission` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP");
$schema->table('group_permission', function (Blueprint $table) {
$table->timestamp('created_at')->nullable()->useCurrent()->change();
});
},
'down' => function (Builder $schema) {

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
if ($schema->getConnection()->getDriverName() === 'sqlite') {
$schema->getConnection()->statement('PRAGMA foreign_keys = OFF');
$schema->getConnection()->statement('PRAGMA writable_schema = ON');
$prefix = $schema->getConnection()->getTablePrefix();
$foreignKeysSqlite = [
'discussions' => <<<SQL
foreign key("user_id") references "{$prefix}users"("id") on delete set null,
foreign key("last_posted_user_id") references "{$prefix}users"("id") on delete set null,
foreign key("hidden_user_id") references "{$prefix}users"("id") on delete set null,
foreign key("first_post_id") references "{$prefix}posts"("id") on delete set null,
foreign key("last_post_id") references "{$prefix}posts"("id") on delete set null
SQL,
'posts' => <<<SQL
foreign key("discussion_id") references "{$prefix}discussions"("id") on delete cascade,
foreign key("user_id") references "{$prefix}users"("id") on delete set null,
foreign key("edited_user_id") references "{$prefix}users"("id") on delete set null,
foreign key("hidden_user_id") references "{$prefix}users"("id") on delete set null
SQL,
];
foreach (['discussions', 'posts'] as $table) {
$tableDefinition = $schema->getConnection()->select('SELECT sql FROM sqlite_master WHERE type = "table" AND name = "'.$prefix.$table.'"')[0]->sql;
$modifiedTableDefinition = str($tableDefinition)->beforeLast(')')->append(', '.$foreignKeysSqlite[$table].')')->toString();
$modifiedTableDefinitionWithEscapedQuotes = str($modifiedTableDefinition)->replace('"', '""')->toString();
$schema->getConnection()->statement('UPDATE sqlite_master SET sql = "'.$modifiedTableDefinitionWithEscapedQuotes.'" WHERE type = "table" AND name = "'.$prefix.$table.'"');
}
$schema->getConnection()->statement('PRAGMA writable_schema = OFF');
$schema->getConnection()->statement('PRAGMA foreign_keys = ON');
}
},
];

View File

@@ -0,0 +1,132 @@
CREATE TABLE IF NOT EXISTS "db_prefix_migrations" ("id" integer primary key autoincrement not null, "migration" varchar not null, "extension" varchar);
CREATE TABLE IF NOT EXISTS "db_prefix_groups" ("id" integer primary key autoincrement not null, "name_singular" varchar not null, "name_plural" varchar not null, "color" varchar, "icon" varchar, "is_hidden" tinyint(1) not null default '0', "created_at" datetime, "updated_at" datetime);
CREATE TABLE IF NOT EXISTS "db_prefix_group_permission" ("group_id" integer not null, "permission" varchar not null, "created_at" datetime, primary key ("group_id", "permission"));
CREATE TABLE IF NOT EXISTS "db_prefix_group_user" ("user_id" integer not null, "group_id" integer not null, "created_at" datetime, primary key ("user_id", "group_id"));
CREATE TABLE db_prefix_settings ("key" VARCHAR(255) NOT NULL, value CLOB DEFAULT NULL, PRIMARY KEY("key"));
CREATE TABLE IF NOT EXISTS "db_prefix_api_keys" ("key" varchar not null, "id" integer primary key autoincrement not null, "allowed_ips" varchar, "scopes" varchar, "user_id" integer, "created_at" datetime not null, "last_activity_at" datetime, foreign key("user_id") references "db_prefix_users"("id") on delete cascade);
CREATE UNIQUE INDEX "db_prefix_api_keys_key_unique" on "db_prefix_api_keys" ("key");
CREATE TABLE db_prefix_discussion_user (user_id INTEGER NOT NULL, discussion_id INTEGER NOT NULL, last_read_at DATETIME DEFAULT NULL, last_read_post_number INTEGER DEFAULT NULL, PRIMARY KEY(user_id, discussion_id));
CREATE TABLE db_prefix_email_tokens (token VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL COLLATE "BINARY", user_id INTEGER NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(token));
CREATE TABLE db_prefix_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER NOT NULL, from_user_id INTEGER DEFAULT NULL, type VARCHAR(255) NOT NULL COLLATE "BINARY", subject_id INTEGER DEFAULT NULL, data BLOB DEFAULT NULL, created_at DATETIME NOT NULL, is_deleted BOOLEAN DEFAULT 0 NOT NULL, read_at DATETIME DEFAULT NULL);
CREATE TABLE db_prefix_password_tokens (token VARCHAR(255) NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(token));
CREATE TABLE IF NOT EXISTS "db_prefix_post_user" ("post_id" integer not null, "user_id" integer not null, foreign key("post_id") references "db_prefix_posts"("id") on delete cascade, foreign key("user_id") references "db_prefix_users"("id") on delete cascade, primary key ("post_id", "user_id"));
CREATE TABLE db_prefix_users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL COLLATE "BINARY", email VARCHAR(255) NOT NULL COLLATE "BINARY", is_email_confirmed BOOLEAN DEFAULT 0 NOT NULL, password VARCHAR(255) NOT NULL COLLATE "BINARY", avatar_url VARCHAR(255) DEFAULT NULL, preferences BLOB DEFAULT NULL, joined_at DATETIME DEFAULT NULL, last_seen_at DATETIME DEFAULT NULL, marked_all_as_read_at DATETIME DEFAULT NULL, read_notifications_at DATETIME DEFAULT NULL, discussion_count INTEGER DEFAULT 0 NOT NULL, comment_count INTEGER DEFAULT 0 NOT NULL);
CREATE UNIQUE INDEX db_prefix_users_email_unique ON db_prefix_users (email);
CREATE UNIQUE INDEX db_prefix_users_username_unique ON db_prefix_users (username);
CREATE INDEX "db_prefix_users_joined_at_index" on "db_prefix_users" ("joined_at");
CREATE INDEX "db_prefix_users_last_seen_at_index" on "db_prefix_users" ("last_seen_at");
CREATE INDEX "db_prefix_users_discussion_count_index" on "db_prefix_users" ("discussion_count");
CREATE INDEX "db_prefix_users_comment_count_index" on "db_prefix_users" ("comment_count");
CREATE INDEX "db_prefix_notifications_user_id_index" on "db_prefix_notifications" ("user_id");
CREATE TABLE db_prefix_registration_tokens (token VARCHAR(255) NOT NULL, payload CLOB DEFAULT NULL, created_at DATETIME NOT NULL, "provider" varchar not null, "identifier" varchar not null, "user_attributes" text, PRIMARY KEY(token));
CREATE TABLE IF NOT EXISTS "db_prefix_login_providers" ("id" integer primary key autoincrement not null, "user_id" integer not null, "provider" varchar not null, "identifier" varchar not null, "created_at" datetime, "last_login_at" datetime, foreign key("user_id") references "db_prefix_users"("id") on delete cascade);
CREATE UNIQUE INDEX "db_prefix_login_providers_provider_identifier_unique" on "db_prefix_login_providers" ("provider", "identifier");
CREATE TABLE IF NOT EXISTS "db_prefix_access_tokens" ("id" integer primary key autoincrement not null, "token" varchar not null, "user_id" integer not null, "last_activity_at" datetime, "created_at" datetime not null, "type" varchar, "title" varchar, "last_ip_address" varchar, "last_user_agent" varchar, foreign key("user_id") references "db_prefix_users"("id") on delete cascade);
CREATE INDEX "db_prefix_access_tokens_type_index" on "db_prefix_access_tokens" ("type");
CREATE UNIQUE INDEX "db_prefix_access_tokens_token_unique" on "db_prefix_access_tokens" ("token");
CREATE TABLE db_prefix_posts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, discussion_id INTEGER NOT NULL, number INTEGER DEFAULT NULL, created_at DATETIME NOT NULL, user_id INTEGER DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, content CLOB DEFAULT NULL --
, edited_at DATETIME DEFAULT NULL, edited_user_id INTEGER DEFAULT NULL, hidden_at DATETIME DEFAULT NULL, hidden_user_id INTEGER DEFAULT NULL, ip_address VARCHAR(255) DEFAULT NULL, is_private BOOLEAN DEFAULT 0 NOT NULL, foreign key("discussion_id") references "db_prefix_discussions"("id") on delete cascade,
foreign key("user_id") references "db_prefix_users"("id") on delete set null,
foreign key("edited_user_id") references "db_prefix_users"("id") on delete set null,
foreign key("hidden_user_id") references "db_prefix_users"("id") on delete set null);
CREATE INDEX db_prefix_posts_user_id_created_at_index ON db_prefix_posts (user_id, created_at);
CREATE INDEX db_prefix_posts_discussion_id_created_at_index ON db_prefix_posts (discussion_id, created_at);
CREATE INDEX db_prefix_posts_discussion_id_number_index ON db_prefix_posts (discussion_id, number);
CREATE UNIQUE INDEX db_prefix_posts_discussion_id_number_unique ON db_prefix_posts (discussion_id, number);
CREATE INDEX "db_prefix_posts_type_index" on "db_prefix_posts" ("type");
CREATE INDEX "db_prefix_posts_type_created_at_index" on "db_prefix_posts" ("type", "created_at");
CREATE TABLE IF NOT EXISTS "db_prefix_unsubscribe_tokens" ("id" integer primary key autoincrement not null, "user_id" integer not null, "email_type" varchar not null, "token" varchar not null, "unsubscribed_at" datetime, "created_at" datetime, "updated_at" datetime, foreign key("user_id") references "db_prefix_users"("id") on delete cascade);
CREATE INDEX "db_prefix_unsubscribe_tokens_user_id_index" on "db_prefix_unsubscribe_tokens" ("user_id");
CREATE INDEX "db_prefix_unsubscribe_tokens_email_type_index" on "db_prefix_unsubscribe_tokens" ("email_type");
CREATE INDEX "db_prefix_unsubscribe_tokens_token_index" on "db_prefix_unsubscribe_tokens" ("token");
CREATE INDEX "db_prefix_unsubscribe_tokens_user_id_email_type_index" on "db_prefix_unsubscribe_tokens" ("user_id", "email_type");
CREATE UNIQUE INDEX "db_prefix_unsubscribe_tokens_token_unique" on "db_prefix_unsubscribe_tokens" ("token");
CREATE TABLE db_prefix_discussions (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL COLLATE "BINARY", comment_count INTEGER DEFAULT 1 NOT NULL, participant_count INTEGER DEFAULT 0 NOT NULL, created_at DATETIME NOT NULL, user_id INTEGER DEFAULT NULL, first_post_id INTEGER DEFAULT NULL, last_posted_at DATETIME DEFAULT NULL, last_posted_user_id INTEGER DEFAULT NULL, last_post_id INTEGER DEFAULT NULL, last_post_number INTEGER DEFAULT NULL, hidden_at DATETIME DEFAULT NULL, hidden_user_id INTEGER DEFAULT NULL, slug VARCHAR(255) NOT NULL COLLATE "BINARY", is_private BOOLEAN DEFAULT 0 NOT NULL, foreign key("user_id") references "db_prefix_users"("id") on delete set null,
foreign key("last_posted_user_id") references "db_prefix_users"("id") on delete set null,
foreign key("hidden_user_id") references "db_prefix_users"("id") on delete set null,
foreign key("first_post_id") references "db_prefix_posts"("id") on delete set null,
foreign key("last_post_id") references "db_prefix_posts"("id") on delete set null);
CREATE INDEX db_prefix_discussions_last_posted_at_index ON db_prefix_discussions (last_posted_at);
CREATE INDEX db_prefix_discussions_last_posted_user_id_index ON db_prefix_discussions (last_posted_user_id);
CREATE INDEX db_prefix_discussions_created_at_index ON db_prefix_discussions (created_at);
CREATE INDEX db_prefix_discussions_user_id_index ON db_prefix_discussions (user_id);
CREATE INDEX db_prefix_discussions_comment_count_index ON db_prefix_discussions (comment_count);
CREATE INDEX db_prefix_discussions_participant_count_index ON db_prefix_discussions (participant_count);
CREATE INDEX db_prefix_discussions_hidden_at_index ON db_prefix_discussions (hidden_at);
INSERT INTO db_prefix_migrations VALUES(1,'2015_02_24_000000_create_access_tokens_table',NULL);
INSERT INTO db_prefix_migrations VALUES(2,'2015_02_24_000000_create_api_keys_table',NULL);
INSERT INTO db_prefix_migrations VALUES(3,'2015_02_24_000000_create_config_table',NULL);
INSERT INTO db_prefix_migrations VALUES(4,'2015_02_24_000000_create_discussions_table',NULL);
INSERT INTO db_prefix_migrations VALUES(5,'2015_02_24_000000_create_email_tokens_table',NULL);
INSERT INTO db_prefix_migrations VALUES(6,'2015_02_24_000000_create_groups_table',NULL);
INSERT INTO db_prefix_migrations VALUES(7,'2015_02_24_000000_create_notifications_table',NULL);
INSERT INTO db_prefix_migrations VALUES(8,'2015_02_24_000000_create_password_tokens_table',NULL);
INSERT INTO db_prefix_migrations VALUES(9,'2015_02_24_000000_create_permissions_table',NULL);
INSERT INTO db_prefix_migrations VALUES(10,'2015_02_24_000000_create_posts_table',NULL);
INSERT INTO db_prefix_migrations VALUES(11,'2015_02_24_000000_create_users_discussions_table',NULL);
INSERT INTO db_prefix_migrations VALUES(12,'2015_02_24_000000_create_users_groups_table',NULL);
INSERT INTO db_prefix_migrations VALUES(13,'2015_02_24_000000_create_users_table',NULL);
INSERT INTO db_prefix_migrations VALUES(14,'2015_09_15_000000_create_auth_tokens_table',NULL);
INSERT INTO db_prefix_migrations VALUES(15,'2015_09_20_224327_add_hide_to_discussions',NULL);
INSERT INTO db_prefix_migrations VALUES(16,'2015_09_22_030432_rename_notification_read_time',NULL);
INSERT INTO db_prefix_migrations VALUES(17,'2015_10_07_130531_rename_config_to_settings',NULL);
INSERT INTO db_prefix_migrations VALUES(18,'2015_10_24_194000_add_ip_address_to_posts',NULL);
INSERT INTO db_prefix_migrations VALUES(19,'2015_12_05_042721_change_access_tokens_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(20,'2015_12_17_194247_change_settings_value_column_to_text',NULL);
INSERT INTO db_prefix_migrations VALUES(21,'2016_02_04_095452_add_slug_to_discussions',NULL);
INSERT INTO db_prefix_migrations VALUES(22,'2017_04_07_114138_add_is_private_to_discussions',NULL);
INSERT INTO db_prefix_migrations VALUES(23,'2017_04_07_114138_add_is_private_to_posts',NULL);
INSERT INTO db_prefix_migrations VALUES(24,'2018_01_11_093900_change_access_tokens_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(25,'2018_01_11_094000_change_access_tokens_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(26,'2018_01_11_095000_change_api_keys_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(27,'2018_01_11_101800_rename_auth_tokens_to_registration_tokens',NULL);
INSERT INTO db_prefix_migrations VALUES(28,'2018_01_11_102000_change_registration_tokens_rename_id_to_token',NULL);
INSERT INTO db_prefix_migrations VALUES(29,'2018_01_11_102100_change_registration_tokens_created_at_to_datetime',NULL);
INSERT INTO db_prefix_migrations VALUES(30,'2018_01_11_120604_change_posts_table_to_innodb',NULL);
INSERT INTO db_prefix_migrations VALUES(31,'2018_01_11_155200_change_discussions_rename_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(32,'2018_01_11_155300_change_discussions_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(33,'2018_01_15_071700_rename_users_discussions_to_discussion_user',NULL);
INSERT INTO db_prefix_migrations VALUES(34,'2018_01_15_071800_change_discussion_user_rename_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(35,'2018_01_15_071900_change_discussion_user_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(36,'2018_01_15_072600_change_email_tokens_rename_id_to_token',NULL);
INSERT INTO db_prefix_migrations VALUES(37,'2018_01_15_072700_change_email_tokens_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(38,'2018_01_15_072800_change_email_tokens_created_at_to_datetime',NULL);
INSERT INTO db_prefix_migrations VALUES(39,'2018_01_18_130400_rename_permissions_to_group_permission',NULL);
INSERT INTO db_prefix_migrations VALUES(40,'2018_01_18_130500_change_group_permission_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(41,'2018_01_18_130600_rename_users_groups_to_group_user',NULL);
INSERT INTO db_prefix_migrations VALUES(42,'2018_01_18_130700_change_group_user_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(43,'2018_01_18_133000_change_notifications_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(44,'2018_01_18_133100_change_notifications_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(45,'2018_01_18_134400_change_password_tokens_rename_id_to_token',NULL);
INSERT INTO db_prefix_migrations VALUES(46,'2018_01_18_134500_change_password_tokens_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(47,'2018_01_18_134600_change_password_tokens_created_at_to_datetime',NULL);
INSERT INTO db_prefix_migrations VALUES(48,'2018_01_18_135000_change_posts_rename_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(49,'2018_01_18_135100_change_posts_add_foreign_keys',NULL);
INSERT INTO db_prefix_migrations VALUES(50,'2018_01_30_112238_add_fulltext_index_to_discussions_title',NULL);
INSERT INTO db_prefix_migrations VALUES(51,'2018_01_30_220100_create_post_user_table',NULL);
INSERT INTO db_prefix_migrations VALUES(52,'2018_01_30_222900_change_users_rename_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(55,'2018_09_15_041340_add_users_indicies',NULL);
INSERT INTO db_prefix_migrations VALUES(56,'2018_09_15_041828_add_discussions_indicies',NULL);
INSERT INTO db_prefix_migrations VALUES(57,'2018_09_15_043337_add_notifications_indices',NULL);
INSERT INTO db_prefix_migrations VALUES(58,'2018_09_15_043621_add_posts_indices',NULL);
INSERT INTO db_prefix_migrations VALUES(59,'2018_09_22_004100_change_registration_tokens_columns',NULL);
INSERT INTO db_prefix_migrations VALUES(60,'2018_09_22_004200_create_login_providers_table',NULL);
INSERT INTO db_prefix_migrations VALUES(61,'2018_10_08_144700_add_shim_prefix_to_group_icons',NULL);
INSERT INTO db_prefix_migrations VALUES(62,'2019_10_12_195349_change_posts_add_discussion_foreign_key',NULL);
INSERT INTO db_prefix_migrations VALUES(63,'2020_03_19_134512_change_discussions_default_comment_count',NULL);
INSERT INTO db_prefix_migrations VALUES(64,'2020_04_21_130500_change_permission_groups_add_is_hidden',NULL);
INSERT INTO db_prefix_migrations VALUES(65,'2021_03_02_040000_change_access_tokens_add_type',NULL);
INSERT INTO db_prefix_migrations VALUES(66,'2021_03_02_040500_change_access_tokens_add_id',NULL);
INSERT INTO db_prefix_migrations VALUES(67,'2021_03_02_041000_change_access_tokens_add_title_ip_agent',NULL);
INSERT INTO db_prefix_migrations VALUES(68,'2021_04_18_040500_change_migrations_add_id_primary_key',NULL);
INSERT INTO db_prefix_migrations VALUES(69,'2021_04_18_145100_change_posts_content_column_to_mediumtext',NULL);
INSERT INTO db_prefix_migrations VALUES(70,'2021_05_10_000000_rename_permissions',NULL);
INSERT INTO db_prefix_migrations VALUES(71,'2022_05_20_000000_add_timestamps_to_groups_table',NULL);
INSERT INTO db_prefix_migrations VALUES(72,'2022_05_20_000001_add_created_at_to_group_user_table',NULL);
INSERT INTO db_prefix_migrations VALUES(73,'2022_05_20_000002_add_created_at_to_group_permission_table',NULL);
INSERT INTO db_prefix_migrations VALUES(74,'2022_07_14_000000_add_type_index_to_posts',NULL);
INSERT INTO db_prefix_migrations VALUES(75,'2022_07_14_000001_add_type_created_at_composite_index_to_posts',NULL);
INSERT INTO db_prefix_migrations VALUES(76,'2022_08_06_000000_change_access_tokens_last_activity_at_to_nullable',NULL);
INSERT INTO db_prefix_migrations VALUES(77,'2023_08_19_000000_create_unsubscribe_tokens_table',NULL);
INSERT INTO db_prefix_migrations VALUES(78,'2023_10_23_000000_drop_post_number_index_column_from_discussions_table',NULL);
INSERT INTO db_prefix_migrations VALUES(79,'2024_05_05_000000_add_sqlite_keys',NULL);

View File

@@ -60,7 +60,8 @@ class AdminPayload
$document->payload['searchDrivers'] = $this->getSearchDrivers();
$document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion();
$document->payload['mysqlVersion'] = $this->appInfo->identifyDatabaseVersion();
$document->payload['dbDriver'] = $this->appInfo->identifyDatabaseDriver();
$document->payload['dbVersion'] = $this->appInfo->identifyDatabaseVersion();
$document->payload['debugEnabled'] = Arr::get($this->config, 'debug');
if ($this->appInfo->scheduledTasksRegistered()) {

View File

@@ -31,13 +31,14 @@ class Index
$extensionsEnabled = json_decode($this->settings->get('extensions_enabled', '{}'), true);
$csrfToken = $request->getAttribute('session')->token();
$mysqlVersion = $document->payload['mysqlVersion'];
$dbDriver = $document->payload['dbDriver'];
$dbVersion = $document->payload['dbVersion'];
$phpVersion = $document->payload['phpVersion'];
$flarumVersion = Application::VERSION;
$document->content = $this->view->make(
'flarum.admin::frontend.content.admin',
compact('extensions', 'extensionsEnabled', 'csrfToken', 'flarumVersion', 'phpVersion', 'mysqlVersion')
compact('extensions', 'extensionsEnabled', 'csrfToken', 'flarumVersion', 'phpVersion', 'dbVersion', 'dbDriver')
);
return $document;

View File

@@ -36,14 +36,18 @@ class WhenSavingSettings
{
if (array_key_exists('safe_mode_extensions', $event->settings)) {
$safeModeExtensions = json_decode($event->settings['safe_mode_extensions'] ?? '[]', true);
$sorted = [];
$extensions = $this->extensions->getExtensions()->filter(function ($extension) use ($safeModeExtensions) {
return in_array($extension->getId(), $safeModeExtensions);
});
if ($safeModeExtensions) {
$extensions = $this->extensions->getExtensions()->filter(function ($extension) use ($safeModeExtensions) {
return in_array($extension->getId(), $safeModeExtensions);
});
$sorted = array_map(fn (Extension $e) => $e->getId(), $this->extensions->sortDependencies($extensions->all()));
$sorted = array_map(fn (Extension $e) => $e->getId(), $this->extensions->sortDependencies($extensions->all()));
$sorted = array_values($sorted);
}
$event->settings['safe_mode_extensions'] = json_encode(array_values($sorted));
$event->settings['safe_mode_extensions'] = json_encode($sorted);
}
}

View File

@@ -9,7 +9,7 @@
namespace Flarum\Api;
use Flarum\Api\Endpoint\EndpointInterface;
use Flarum\Api\Endpoint\Endpoint;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ErrorHandling\JsonApiFormatter;
use Flarum\Foundation\ErrorHandling\Registry;
@@ -22,7 +22,6 @@ use Flarum\Http\UrlGenerator;
use Illuminate\Contracts\Container\Container;
use Laminas\Stratigility\MiddlewarePipe;
use ReflectionClass;
use Tobyz\JsonApiServer\Endpoint\Endpoint;
class ApiServiceProvider extends AbstractServiceProvider
{
@@ -53,7 +52,7 @@ class ApiServiceProvider extends AbstractServiceProvider
$api->container($container);
foreach ($resources as $resourceClass) {
/** @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource */
/** @var \Flarum\Api\Resource\AbstractResource $resource */
$resource = $container->make($resourceClass);
$api->resource($resource->boot($api));
}
@@ -189,7 +188,7 @@ class ApiServiceProvider extends AbstractServiceProvider
*
* We avoid dependency injection here to avoid early resolution.
*
* @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource
* @var \Flarum\Api\Resource\AbstractResource $resource
*/
$resource = (new ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
@@ -199,7 +198,7 @@ class ApiServiceProvider extends AbstractServiceProvider
* None of the injected dependencies should be directly used within
* the `endpoints` method. Encourage using callbacks.
*
* @var array<Endpoint&EndpointInterface> $endpoints
* @var array<Endpoint> $endpoints
*/
$endpoints = $resource->resolveEndpoints(true);

View File

@@ -12,10 +12,18 @@ namespace Flarum\Api;
use Flarum\Http\RequestUtil;
use Flarum\Search\SearchResults;
use Flarum\User\User;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Context as BaseContext;
use Tobyz\JsonApiServer\Resource\Resource;
use Tobyz\JsonApiServer\Schema\Field\Field;
use WeakMap;
class Context extends BaseContext
{
private WeakMap $fields;
public int|string|null $modelId = null;
public ?array $requestIncludes = null;
protected ?SearchResults $search = null;
/**
@@ -29,6 +37,34 @@ class Context extends BaseContext
*/
protected array $parameters = [];
public function __construct(\Tobyz\JsonApiServer\JsonApi $api, ServerRequestInterface $request)
{
$this->fields = new WeakMap();
parent::__construct($api, $request);
}
/**
* Get the fields for the given resource, keyed by name.
*
* @return array<string, Field>
*/
public function fields(Resource $resource): array
{
if (isset($this->fields[$resource])) {
return $this->fields[$resource];
}
$fields = [];
// @phpstan-ignore-next-line
foreach ($resource->resolveFields() as $field) {
$fields[$field->name] = $field;
}
return $this->fields[$resource] = $fields;
}
public function withSearchResults(SearchResults $search): static
{
$new = clone $this;
@@ -96,4 +132,47 @@ class Context extends BaseContext
{
return $this->endpoint instanceof Endpoint\Index && (! $resource || is_a($this->collection, $resource));
}
public function withRequest(ServerRequestInterface $request): static
{
$new = parent::withRequest($request);
$new->requestIncludes = null;
return $new;
}
public function withModelId(int|string|null $id): static
{
$new = clone $this;
$new->modelId = $id;
return $new;
}
public function withRequestIncludes(array $requestIncludes): static
{
$new = clone $this;
$new->requestIncludes = $requestIncludes;
return $new;
}
public function extractIdFromPath(BaseContext $context): ?string
{
/** @var Endpoint\Endpoint $endpoint */
$endpoint = $context->endpoint;
$currentPath = trim($context->path(), '/');
$path = trim($context->collection->name().$endpoint->path, '/');
if (! str_contains($path, '{id}')) {
return null;
}
$segments = explode('/', $path);
$idSegmentIndex = array_search('{id}', $segments);
$currentPathSegments = explode('/', $currentPath);
return $currentPathSegments[$idSegmentIndex] ?? null;
}
}

View File

@@ -10,9 +10,9 @@
namespace Flarum\Api\Endpoint\Concerns;
use Closure;
use Flarum\Api\Resource\AbstractResource;
use Flarum\Http\RequestUtil;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Resource\AbstractResource;
use Tobyz\JsonApiServer\Schema\Sort;
trait ExtractsListingParams
@@ -110,11 +110,13 @@ trait ExtractsListingParams
public function getAvailableSorts(Context $context): array
{
if (! $context->collection instanceof AbstractResource) {
$collection = $context->collection;
if (! $collection instanceof AbstractResource) {
return [];
}
$asc = collect($context->collection->resolveSorts())
$asc = collect($collection->resolveSorts())
->filter(fn (Sort $field) => $field->isVisible($context))
->pluck('name')
->toArray();

View File

@@ -14,9 +14,14 @@ use Flarum\Http\RequestUtil;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\Exception\PermissionDeniedException;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility;
trait HasAuthorization
{
use HasVisibility {
isVisible as parentIsVisible;
}
protected bool|Closure $authenticated = false;
protected null|string|Closure $ability = null;
@@ -86,6 +91,6 @@ trait HasAuthorization
$actor->assertCan($ability, $context->model);
}
return parent::isVisible($context);
return $this->parentIsVisible($context);
}
}

View File

@@ -14,6 +14,10 @@ use Tobyz\JsonApiServer\Context;
trait HasCustomHooks
{
use HasHooks {
resolveCallable as protected resolveHookCallable;
}
protected function resolveCallable(callable|string $callable, Context $context): callable
{
if (is_string($callable)) {

View File

@@ -0,0 +1,213 @@
<?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\Api\Resource\AbstractDatabaseResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
use Tobyz\JsonApiServer\Context;
/**
* This is directed at eager loading relationships apart from the request includes.
*/
trait HasEagerLoading
{
/**
* @var array<string|callable>
*/
protected array $loadRelations = [];
/**
* @var array<string, callable>
*/
protected array $loadRelationWhere = [];
/**
* Eager loads relationships needed for serializer logic.
*
* @param string|string[] $relations
*/
public function eagerLoad(array|string|Closure $relations): static
{
if (! is_callable($relations)) {
$this->loadRelations = array_merge($this->loadRelations, array_map('strval', (array) $relations));
} else {
$this->loadRelations[] = $relations;
}
return $this;
}
/**
* Eager load relations when a relation is included in the serialized response.
*
* @param array<string, array<string>> $includedToRelations An array of included relation to relations to load 'includedRelation' => ['relation1', 'relation2']
*/
public function eagerLoadWhenIncluded(array $includedToRelations): static
{
return $this->eagerLoad(function (array $included) use ($includedToRelations) {
$relations = [];
foreach ($includedToRelations as $includedRelation => $includedRelations) {
if (in_array($includedRelation, $included)) {
$relations = array_merge($relations, $includedRelations);
}
}
return $relations;
});
}
/**
* Allows loading a relationship with additional query modification.
*
* @param string $relation: Relationship name, see load method description.
* @param callable $callback
*
* The callback to modify the query, should accept:
* - \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query: A query object.
* - Context $context: An instance of the API context.
* - array $relations: An array of relations that are to be loaded.
*/
public function eagerLoadWhere(string $relation, callable $callback): static
{
$this->loadRelationWhere = array_merge($this->loadRelationWhere, [$relation => $callback]);
return $this;
}
/**
* Eager loads relationships before serialization.
*/
protected function loadRelations(Collection $models, Context $context, array $included = []): void
{
if (! $context->collection instanceof AbstractDatabaseResource) {
return;
}
$included = $this->stringInclude($included);
$models = $models->filter(fn ($model) => $model instanceof Model);
$relations = $this->compileSimpleEagerLoads($context, $included);
$addedRelationWhere = $this->compileWhereEagerLoads($context);
foreach ($addedRelationWhere as $name => $callable) {
$relations[] = $name;
}
if (! empty($relations)) {
$relations = array_unique($relations);
}
$whereRelations = [];
$simpleRelations = [];
foreach ($relations as $relation) {
if (isset($addedRelationWhere[$relation])) {
$whereRelations[$relation] = $addedRelationWhere[$relation];
} else {
$simpleRelations[] = $relation;
}
}
if (! empty($whereRelations)) {
$models->loadMissing($whereRelations);
}
if (! empty($simpleRelations)) {
$models->loadMissing($simpleRelations);
}
}
protected function compileSimpleEagerLoads(Context $context, array $included): array
{
$relations = [];
foreach ($this->loadRelations as $relation) {
if (is_callable($relation)) {
$returnedRelations = $relation($included, $context);
$relations = array_merge($relations, array_map('strval', (array) $returnedRelations));
} else {
$relations[] = $relation;
}
}
return $relations;
}
protected function compileWhereEagerLoads(Context $context): array
{
$relations = [];
foreach ($this->loadRelationWhere as $name => $callable) {
$relations[$name] = function ($query) use ($callable, $context) {
$callable($query, $context);
};
}
return $relations;
}
public function getEagerLoadsFor(string $included, Context $context): array
{
$subRelations = [];
$includes = $this->stringInclude($this->getInclude($context));
foreach ($this->compileSimpleEagerLoads($context, $includes) as $relation) {
if (! is_callable($relation)) {
if (Str::startsWith($relation, "$included.")) {
$subRelations[] = Str::after($relation, "$included.");
}
} else {
$returnedRelations = $relation($includes, $context);
$subRelations = array_merge($subRelations, array_map('strval', (array) $returnedRelations));
}
}
return $subRelations;
}
public function getWhereEagerLoadsFor(string $included, Context $context): array
{
$subRelations = [];
foreach ($this->loadRelationWhere as $relation => $callable) {
if (Str::startsWith($relation, "$included.")) {
$subRelations[$relation] = Str::after($relation, "$included.");
}
}
return $subRelations;
}
/**
* From format of: 'relation' => [ ...nested ] to ['relation', 'relation.nested'].
*/
private function stringInclude(array $include): array
{
$relations = [];
foreach ($include as $relation => $nested) {
$relations[] = $relation;
if (is_array($nested)) {
foreach ($this->stringInclude($nested) as $nestedRelation) {
$relations[] = $relation.'.'.$nestedRelation;
}
}
}
return $relations;
}
}

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\Endpoint\Concerns;
use RuntimeException;
use Tobyz\JsonApiServer\Context;
trait HasHooks
{
protected array $before = [];
protected array $after = [];
public function before(callable|string $callback): static
{
$this->before[] = $callback;
return $this;
}
public function after(callable|string $callback): static
{
$this->after[] = $callback;
return $this;
}
protected function resolveCallable(callable|string $callable, Context $context): callable
{
if (is_string($callable)) {
return new $callable();
}
return $callable;
}
protected function callBeforeHook(Context $context): void
{
foreach ($this->before as $before) {
$before = $this->resolveCallable($before, $context);
$before($context);
}
}
protected function callAfterHook(Context $context, mixed $data): mixed
{
foreach ($this->after as $after) {
$after = $this->resolveCallable($after, $context);
$data = $after($context, $data);
if (empty($data)) {
throw new RuntimeException('The after hook must return the data back.');
}
}
return $data;
}
}

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\Endpoint\Concerns;
trait IncludesData
{
use \Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
public function addDefaultInclude(array $include): static
{
$this->defaultInclude = array_merge($this->defaultInclude ?? [], $include);
return $this;
}
public function removeDefaultInclude(array $include): static
{
$this->defaultInclude = array_diff($this->defaultInclude ?? [], $include);
return $this;
}
}

View File

@@ -0,0 +1,175 @@
<?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\Api\Schema\Concerns\HasValidationRules;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Translation\ArrayLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Factory;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Endpoint\Concerns\SavesData;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ConflictException;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
use Tobyz\JsonApiServer\Schema\Field\Attribute;
trait SavesAndValidatesData
{
use SavesData {
parseData as protected parentParseData;
}
/**
* Assert that the field values within a data object pass validation.
*
* @param \Flarum\Api\Context $context
* @throws UnprocessableEntityException
*/
protected function assertDataValid(Context $context, array $data): void
{
$this->mutateDataBeforeValidation($context, $data);
$collection = $context->collection;
$rules = [
'attributes' => [],
'relationships' => [],
];
$messages = [];
$attributes = [];
foreach ($context->fields($context->resource) as $field) {
$writable = $field->isWritable($context->withField($field));
if (! $writable || ! in_array(HasValidationRules::class, class_uses_recursive($field))) {
continue;
}
$type = $field instanceof Attribute ? 'attributes' : 'relationships';
// @phpstan-ignore-next-line
$rules[$type] = array_merge($rules[$type], $field->getValidationRules($context));
// @phpstan-ignore-next-line
$messages = array_merge($messages, $field->getValidationMessages($context));
// @phpstan-ignore-next-line
$attributes = array_merge($attributes, $field->getValidationAttributes($context));
}
if (method_exists($collection, 'validationFactory')) {
$factory = $collection->validationFactory();
} else {
$loader = new ArrayLoader();
$translator = new Translator($loader, 'en');
$factory = new Factory($translator);
}
$attributeValidator = $factory->make($data['attributes'], $rules['attributes'], $messages, $attributes);
$relationshipValidator = $factory->make($data['relationships'], $rules['relationships'], $messages, $attributes);
$this->validate('attributes', $attributeValidator);
$this->validate('relationships', $relationshipValidator);
}
/**
* @throws UnprocessableEntityException if any fields do not pass validation.
*/
protected function validate(string $type, Validator $validator): void
{
if ($validator->fails()) {
$errors = [];
foreach ($validator->errors()->messages() as $field => $messages) {
$errors[] = [
'source' => ['pointer' => "/data/$type/$field"],
'detail' => implode(' ', $messages),
];
}
throw new UnprocessableEntityException($errors);
}
}
protected function mutateDataBeforeValidation(Context $context, array $data): array
{
if (method_exists($context->resource, 'mutateDataBeforeValidation')) {
return $context->resource->mutateDataBeforeValidation($context, $data);
}
return $data;
}
/**
* Parse and validate a JSON:API document's `data` member.
*
* @throws BadRequestException if the `data` member is invalid.
*/
final protected function parseData(Context $context): array
{
$body = (array) $context->body();
if (! isset($body['data']) || ! is_array($body['data'])) {
throw (new BadRequestException('data must be an object'))->setSource([
'pointer' => '/data',
]);
}
if (! isset($body['data']['type'])) {
if (isset($context->collection->resources()[0])) {
$body['data']['type'] = $context->collection->resources()[0];
} else {
throw (new BadRequestException('data.type must be present'))->setSource([
'pointer' => '/data/type',
]);
}
}
if (isset($context->model)) {
// commented out to reduce strictness.
// if (!isset($body['data']['id'])) {
// throw (new BadRequestException('data.id must be present'))->setSource([
// 'pointer' => '/data/id',
// ]);
// }
if (isset($body['data']['id']) && $body['data']['id'] !== $context->resource->getId($context->model, $context)) {
throw (new ConflictException('data.id does not match the resource ID'))->setSource([
'pointer' => '/data/id',
]);
}
} elseif (isset($body['data']['id'])) {
throw (new ForbiddenException('Client-generated IDs are not supported'))->setSource([
'pointer' => '/data/id',
]);
}
if (! in_array($body['data']['type'], $context->collection->resources())) {
throw (new ConflictException(
'collection does not support this resource type',
))->setSource(['pointer' => '/data/type']);
}
if (array_key_exists('attributes', $body['data']) && ! is_array($body['data']['attributes'])) {
throw (new BadRequestException('data.attributes must be an object'))->setSource([
'pointer' => '/data/attributes',
]);
}
if (array_key_exists('relationships', $body['data']) && ! is_array($body['data']['relationships'])) {
throw (new BadRequestException('data.relationships must be an object'))->setSource([
'pointer' => '/data/relationships',
]);
}
return array_merge(['attributes' => [], 'relationships' => []], $body['data']);
}
}

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\Endpoint\Concerns;
use Flarum\Api\Serializer;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
trait ShowsResources
{
use HasMeta;
use IncludesData;
protected function showResource(Context $context, mixed $model): array
{
$serializer = new Serializer($context);
$serializer->addPrimary(
$context->resource($context->collection->resource($model, $context)),
$model,
$this->getInclude($context),
);
[$primary, $included] = $serializer->serialize();
$document = ['data' => $primary[0]];
if (count($included)) {
$document['included'] = $included;
}
if ($meta = $this->serializeMeta($context)) {
$document['meta'] = $meta;
}
return $document;
}
}

View File

@@ -9,17 +9,89 @@
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Create as BaseCreate;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData;
use Flarum\Api\Endpoint\Concerns\ShowsResources;
use Flarum\Api\Resource\AbstractResource;
use Flarum\Database\Eloquent\Collection;
use RuntimeException;
use Tobyz\JsonApiServer\Resource\Creatable;
class Create extends BaseCreate implements EndpointInterface
use function Tobyz\JsonApiServer\has_value;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\set_value;
class Create extends Endpoint
{
use SavesAndValidatesData;
use ShowsResources;
use IncludesData;
use HasAuthorization;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'create');
}
public function setUp(): void
{
parent::setUp();
$this->route('POST', '/')
->action(function (Context $context): ?object {
if (str_contains($context->path(), '/')) {
return null;
}
$collection = $context->collection;
if (! $collection instanceof Creatable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($collection), Creatable::class),
);
}
$this->callBeforeHook($context);
$data = $this->parseData($context);
/** @var AbstractResource $resource */
$resource = $context->resource($data['type']);
$context = $context
->withResource($resource)
->withModel($model = $collection->newModel($context));
$this->assertFieldsValid($context, $data);
$this->fillDefaultValues($context, $data);
$this->deserializeValues($context, $data);
$this->assertDataValid($context, $data);
$this->setValues($context, $data);
$context = $context->withModel($model = $resource->createAction($model, $context));
$this->saveFields($context, $data);
return $this->callAfterHook($context, $model);
})
->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
})
->response(function (Context $context, object $model) {
return json_api_response($document = $this->showResource($context, $model))
->withStatus(201)
->withHeader('Location', $document['data']['links']['self']);
});
}
final protected function fillDefaultValues(Context $context, array &$data): void
{
foreach ($context->fields($context->resource) as $field) {
if (! has_value($data, $field) && ($default = $field->default)) {
set_value($data, $field, $default($context->withField($field)));
}
}
}
}

View File

@@ -9,12 +9,59 @@
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Delete as BaseDelete;
use Flarum\Api\Resource\AbstractResource;
use Nyholm\Psr7\Response;
use RuntimeException;
use Tobyz\JsonApiServer\Resource\Deletable;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
class Delete extends BaseDelete implements EndpointInterface
use function Tobyz\JsonApiServer\json_api_response;
class Delete extends Endpoint
{
use HasMeta;
use HasAuthorization;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'delete');
}
public function setUp(): void
{
$this->route('DELETE', '/{id}')
->action(function (Context $context) {
$model = $context->model;
/** @var AbstractResource $resource */
$resource = $context->resource($context->collection->resource($model, $context));
$context = $context->withResource($resource);
if (! $resource instanceof Deletable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($resource), Deletable::class),
);
}
$this->callBeforeHook($context);
$resource->deleteAction($model, $context);
$this->callAfterHook($context, $model);
return null;
})
->response(function (Context $context) {
if ($meta = $this->serializeMeta($context)) {
return json_api_response(['meta' => $meta]);
}
return new Response(204);
});
}
}

View File

@@ -9,14 +9,145 @@
namespace Flarum\Api\Endpoint;
use Closure;
use Flarum\Api\Context;
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;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Flarum\Api\Endpoint\Concerns\ShowsResources;
use Flarum\Api\Resource\AbstractResource;
use Psr\Http\Message\ResponseInterface as Response;
use RuntimeException;
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
class Endpoint extends BaseEndpoint implements EndpointInterface
use function Tobyz\JsonApiServer\json_api_response;
class Endpoint implements \Tobyz\JsonApiServer\Endpoint\Endpoint
{
use ShowsResources;
use FindsResources;
use HasEagerLoading;
use HasAuthorization;
use HasCustomHooks;
use ExtractsListingParams;
public string $method;
public string $path;
protected ?Closure $action = null;
protected ?Closure $response = null;
protected array $beforeSerialization = [];
public function __construct(
public string $name
) {
}
public static function make(?string $name): static
{
$endpoint = new static($name);
$endpoint->setUp();
return $endpoint;
}
protected function setUp(): void
{
}
public function name(string $name): static
{
$this->name = $name;
return $this;
}
public function action(Closure $action): static
{
$this->action = $action;
return $this;
}
public function response(Closure $response): static
{
$this->response = $response;
return $this;
}
public function route(string $method, string $path): static
{
$this->method = $method;
$this->path = '/'.ltrim(rtrim($path, '/'), '/');
return $this;
}
public function beforeSerialization(Closure $callback): static
{
$this->beforeSerialization[] = $callback;
return $this;
}
public function process(Context $context): mixed
{
if (! $this->action) {
throw new RuntimeException('No action defined for endpoint ['.static::class.']');
}
return ($this->action)($context);
}
/**
* @param Context $context
*/
public function handle(\Tobyz\JsonApiServer\Context $context): ?Response
{
if (! isset($this->method, $this->path)) {
throw new RuntimeException('No route defined for endpoint ['.static::class.']');
}
if (strtolower($context->method()) !== strtolower($this->method)) {
throw new MethodNotAllowedException();
}
/** @var AbstractResource $collection */
$collection = $context->collection;
$context = $context->withModelId(
$collection->id($context)
);
if ($context->modelId) {
$context = $context->withModel(
$this->findResource($context, $context->modelId)
);
}
if (! $this->isVisible($context)) {
throw new ForbiddenException();
}
$data = $this->process($context);
foreach ($this->beforeSerialization as $callback) {
$callback($context, $data);
}
if ($this->response) {
return ($this->response)($context, $data);
}
if ($context->model && $data instanceof $context->model) {
return json_api_response($this->showResource($context, $data));
}
return null;
}
}

View File

@@ -9,28 +9,66 @@
namespace Flarum\Api\Endpoint;
use Closure;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Resource\AbstractResource;
use Flarum\Api\Resource\Contracts\Countable;
use Flarum\Api\Resource\Contracts\Listable;
use Flarum\Api\Serializer;
use Flarum\Database\Eloquent\Collection;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Tobyz\JsonApiServer\Endpoint\Index as BaseIndex;
use Psr\Http\Message\ResponseInterface as Response;
use RuntimeException;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\Sourceable;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Pagination\Pagination;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
class Index extends BaseIndex implements EndpointInterface
use function Tobyz\JsonApiServer\apply_filters;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\parse_sort_string;
class Index extends Endpoint
{
use HasMeta;
use IncludesData;
use HasAuthorization;
use ExtractsListingParams;
use HasCustomHooks;
public function setUp(): void
{
parent::setUp();
public Closure $paginationResolver;
public ?string $defaultSort = null;
protected ?Closure $query = null;
$this
public function __construct(string $name)
{
parent::__construct($name);
$this->paginationResolver = fn () => null;
}
public static function make(?string $name = null): static
{
return parent::make($name ?? 'index');
}
public function query(?Closure $query): static
{
$this->query = $query;
return $this;
}
protected function setUp(): void
{
$this->route('GET', '/')
->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.
@@ -63,13 +101,163 @@ class Index extends BaseIndex implements EndpointInterface
$this->applySorts($query, $context);
$this->applyFilters($query, $context);
$pagination?->apply($query);
if ($pagination && method_exists($pagination, 'apply')) {
$pagination->apply($query);
}
}
return $context;
})
->action(function (\Tobyz\JsonApiServer\Context $context) {
if (str_contains($context->path(), '/')) {
return null;
}
$collection = $context->collection;
if (! $collection instanceof Listable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($collection), Listable::class),
);
}
$this->callBeforeHook($context);
$query = $collection->query($context);
$pagination = ($this->paginationResolver)($context);
if ($this->query) {
$context = ($this->query)($query, $pagination, $context);
if (! $context instanceof Context) {
throw new RuntimeException('The Index endpoint query closure must return a Context instance.');
}
} else {
/** @var Context $context */
$context = $context->withQuery($query);
$this->applySorts($query, $context);
$this->applyFilters($query, $context);
if ($pagination) {
$pagination->apply($query);
}
}
$meta = $this->serializeMeta($context);
if (
$collection instanceof Countable &&
! is_null($total = $collection->count($query, $context))
) {
$meta['page']['total'] = $total;
}
$models = $collection->results($query, $context);
$models = $this->callAfterHook($context, $models);
$total ??= null;
return compact('models', 'meta', 'pagination', 'total');
})
->beforeSerialization(function (Context $context, array $results) {
// @phpstan-ignore-next-line
$this->loadRelations(Collection::make($results['models']), $context, $this->getInclude($context));
})
->response(function (Context $context, array $results): Response {
$collection = $context->collection;
['models' => $models, 'meta' => $meta, 'pagination' => $pagination, 'total' => $total] = $results;
$serializer = new Serializer($context);
$include = $this->getInclude($context);
foreach ($models as $model) {
$serializer->addPrimary(
$context->resource($collection->resource($model, $context)),
$model,
$include,
);
}
[$data, $included] = $serializer->serialize();
$links = [];
if ($pagination) {
$meta['page'] = array_merge($meta['page'] ?? [], $pagination->meta());
$links = array_merge($links, $pagination->links(count($data), $total));
}
return json_api_response(compact('data', 'included', 'meta', 'links'));
});
}
public function defaultSort(?string $defaultSort): static
{
$this->defaultSort = $defaultSort;
return $this;
}
final protected function applySorts($query, Context $context): void
{
if (! ($sortString = $context->queryParam('sort', $this->defaultSort))) {
return;
}
$collection = $context->collection;
if (! $collection instanceof AbstractResource) {
throw new RuntimeException('The collection '.$collection::class.' must extend '.AbstractResource::class);
}
$sorts = $collection->resolveSorts();
foreach (parse_sort_string($sortString) as [$name, $direction]) {
foreach ($sorts as $field) {
if ($field->name === $name && $field->isVisible($context)) {
$field->apply($query, $direction, $context);
continue 2;
}
}
throw (new BadRequestException("Invalid sort: $name"))->setSource([
'parameter' => 'sort',
]);
}
}
final protected function applyFilters($query, Context $context): void
{
if (! ($filters = $context->queryParam('filter'))) {
return;
}
if (! is_array($filters)) {
throw (new BadRequestException('filter must be an array'))->setSource([
'parameter' => 'filter',
]);
}
$collection = $context->collection;
if (! $collection instanceof \Tobyz\JsonApiServer\Resource\Listable) {
throw new RuntimeException(
sprintf('%s must implement %s', $collection::class, \Tobyz\JsonApiServer\Resource\Listable::class),
);
}
try {
apply_filters($query, $filters, $collection, $context);
} catch (Sourceable $e) {
throw $e->prependSource(['parameter' => 'filter']);
}
}
public function paginate(int $defaultLimit = 20, int $maxLimit = 50): static
{
$this->limit = $defaultLimit;

View File

@@ -9,19 +9,37 @@
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 Tobyz\JsonApiServer\Endpoint\Show as BaseShow;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Endpoint\Concerns\ShowsResources;
use Flarum\Database\Eloquent\Collection;
class Show extends BaseShow implements EndpointInterface
class Show extends Endpoint
{
use ShowsResources;
use IncludesData;
use HasAuthorization;
use ExtractsListingParams;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'show');
}
public function setUp(): void
{
parent::setUp();
$this->route('GET', '/{id}')
->action(function (Context $context): ?object {
$this->callBeforeHook($context);
return $this->callAfterHook($context, $context->model);
})
->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
});
}
}

View File

@@ -9,17 +9,64 @@
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Update as BaseUpdate;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData;
use Flarum\Api\Endpoint\Concerns\ShowsResources;
use Flarum\Api\Resource\AbstractResource;
use Flarum\Database\Eloquent\Collection;
use RuntimeException;
use Tobyz\JsonApiServer\Resource\Updatable;
class Update extends BaseUpdate implements EndpointInterface
class Update extends Endpoint
{
use SavesAndValidatesData;
use ShowsResources;
use IncludesData;
use HasAuthorization;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'update');
}
public function setUp(): void
{
parent::setUp();
$this->route('PATCH', '/{id}')
->action(function (Context $context): object {
$model = $context->model;
/** @var AbstractResource $resource */
$resource = $context->resource($context->collection->resource($model, $context));
$context = $context->withResource($resource);
if (! $resource instanceof Updatable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($resource), Updatable::class),
);
}
$this->callBeforeHook($context);
$data = $this->parseData($context);
$this->assertFieldsValid($context, $data);
$this->deserializeValues($context, $data);
$this->assertDataValid($context, $data);
$this->setValues($context, $data);
$context = $context->withModel($model = $resource->updateAction($model, $context));
$this->saveFields($context, $data);
return $this->callAfterHook($context, $model);
})
->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
});
}
}

View File

@@ -9,24 +9,26 @@
namespace Flarum\Api;
use Flarum\Api\Endpoint\EndpointInterface;
use Flarum\Api\Endpoint\Endpoint;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Resource\AbstractResource;
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 RuntimeException;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\JsonApi as BaseJsonApi;
use Tobyz\JsonApiServer\Resource\Collection;
use Tobyz\JsonApiServer\Resource\Resource;
class JsonApi extends BaseJsonApi
{
protected ?string $resourceClass = null;
protected ?string $endpointName = null;
protected string $resourceClass;
protected string $endpointName;
protected ?Request $baseRequest = null;
protected ?Container $container = null;
@@ -57,9 +59,13 @@ class JsonApi extends BaseJsonApi
->withEndpoint($this->findEndpoint($collection));
}
protected function findEndpoint(?Collection $collection): Endpoint&EndpointInterface
protected function findEndpoint(?Collection $collection): Endpoint
{
/** @var Endpoint&EndpointInterface $endpoint */
if (! $collection instanceof AbstractResource) {
throw new RuntimeException('Resource '.$collection::class.' must extend '.AbstractResource::class);
}
/** @var Endpoint $endpoint */
foreach ($collection->resolveEndpoints() as $endpoint) {
if ($endpoint->name === $this->endpointName) {
return $endpoint;
@@ -69,6 +75,46 @@ class JsonApi extends BaseJsonApi
throw new BadRequestException('Invalid endpoint specified');
}
/**
* Get a collection by name or class.
*
* @throws ResourceNotFoundException if the collection has not been defined.
*/
public function getCollection(string $type): Collection
{
if (isset($this->collections[$type])) {
return $this->collections[$type];
}
foreach ($this->collections as $instance) {
if ($instance instanceof $type) {
return $instance;
}
}
throw new ResourceNotFoundException($type);
}
/**
* Get a resource by type or class.
*
* @throws ResourceNotFoundException if the resource has not been defined.
*/
public function getResource(string $type): Resource
{
if (isset($this->resources[$type])) {
return $this->resources[$type];
}
foreach ($this->resources as $instance) {
if ($instance instanceof $type) {
return $instance;
}
}
throw new ResourceNotFoundException($type);
}
public function withRequest(Request $request): self
{
$this->baseRequest = $request;
@@ -112,13 +158,19 @@ class JsonApi extends BaseJsonApi
$context = $context->withInternal($key, $value);
}
$endpoint = $context->endpoint;
if (! $endpoint instanceof Endpoint) {
throw new RuntimeException('The endpoint '.$endpoint::class.' must extend '.Endpoint::class);
}
$context = $context->withRequest(
$request
->withMethod($context->endpoint->method)
->withUri(new Uri($context->endpoint->path))
->withMethod($endpoint->method)
->withUri(new Uri($endpoint->path))
);
return $context->endpoint->process($context);
return $endpoint->process($context);
}
public function validateQueryParameters(Request $request): void

View File

@@ -10,37 +10,43 @@
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 Flarum\Api\Schema\Contracts\RelationAggregator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Laravel\EloquentResource as BaseResource;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Schema\Field\Attribute;
use Tobyz\JsonApiServer\Schema\Field\Field;
use Tobyz\JsonApiServer\Schema\Field\Relationship;
use Tobyz\JsonApiServer\Schema\Field\ToMany;
use Tobyz\JsonApiServer\Schema\Type\DateTime;
/**
* @template M of Model
* @extends BaseResource<M, FlarumContext>
* @extends AbstractResource<M>
*/
abstract class AbstractDatabaseResource extends BaseResource
abstract class AbstractDatabaseResource extends AbstractResource implements
Contracts\Findable,
Contracts\Listable,
Contracts\Countable,
Contracts\Paginatable,
Contracts\Creatable,
Contracts\Updatable,
Contracts\Deletable
{
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());
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function resource(object $model, Context $context): ?string
{
$baseModel = $this->model();
@@ -52,134 +58,128 @@ abstract class AbstractDatabaseResource extends BaseResource
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
public function getId(object $model, Context $context): string
{
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;
return $model->getKey();
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function deleting(object $model, Context $context): void
public function getValue(object $model, Field $field, Context $context): mixed
{
//
}
/**
* @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);
if ($field instanceof Relationship) {
return $this->getRelationshipValue($model, $field, $context);
} else {
return $this->getAttributeValue($model, $field, $context);
}
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function mutateDataBeforeValidation(Context $context, array $data): array
protected function getAttributeValue(Model $model, Field $field, Context $context): mixed
{
return $data;
if ($field instanceof RelationAggregator && ($aggregate = $field->getRelationAggregate())) {
$relationName = $aggregate['relation'];
if (! $model->isRelation($relationName)) {
return $model->getAttribute($this->property($field));
}
/** @var Relationship|null $relationship */
$relationship = collect($context->fields($this))->first(fn ($f) => $f->name === $relationName);
if (! $relationship) {
throw new InvalidArgumentException("To use relation aggregates, the relationship field must be part of the resource. Missing field: $relationName for attribute $field->name.");
}
EloquentBuffer::add($model, $relationName, $aggregate);
return function () use ($model, $relationName, $relationship, $field, $context, $aggregate) {
EloquentBuffer::load($model, $relationName, $relationship, $context, $aggregate);
return $model->getAttribute($this->property($field));
};
}
return $model->getAttribute($this->property($field));
}
/**
* @param M $model
* @param FlarumContext $context
*/
protected function getRelationshipValue(Model $model, Relationship $field, Context $context): mixed
{
$method = $this->method($field);
if ($model->isRelation($method)) {
$relation = $model->$method();
// If this is a belongs-to relationship, and we only need to get the ID
// for linkage, then we don't have to actually load the relation because
// the ID is stored in a column directly on the model. We will mock up a
// related model with the value of the ID filled.
if ($relation instanceof BelongsTo && $context->include === null) {
if ($key = $model->getAttribute($relation->getForeignKeyName())) {
if ($relation instanceof MorphTo) {
$morphType = $model->{$relation->getMorphType()};
$related = $relation->createModelByType($morphType);
} else {
$related = $relation->getRelated();
}
return $related->newInstance()->forceFill([$related->getKeyName() => $key]);
}
return null;
}
EloquentBuffer::add($model, $method);
return function () use ($model, $method, $field, $context) {
EloquentBuffer::load($model, $method, $field, $context);
$data = $model->getRelation($method);
return $data instanceof Collection ? $data->all() : $data;
};
}
return $this->getAttributeValue($model, $field, $context);
}
/**
* @param FlarumContext $context
*/
public function query(Context $context): object
{
$query = $this->newModel($context)->query();
$this->scope($query, $context);
return $query;
}
/**
* Hook to scope a query for this resource.
*
* @param Builder<M> $query
* @param FlarumContext $context
*/
public function scope(Builder $query, Context $context): void
{
}
/**
* @param Builder<M> $query
* @param FlarumContext $context
*/
public function results(object $query, Context $context): iterable
@@ -192,6 +192,15 @@ abstract class AbstractDatabaseResource extends BaseResource
}
/**
* @param Builder<M> $query
*/
public function paginate(object $query, OffsetPagination $pagination): void
{
$query->take($pagination->limit)->skip($pagination->offset);
}
/**
* @param Builder<M> $query
* @param FlarumContext $context
*/
public function count(object $query, Context $context): ?int
@@ -200,6 +209,142 @@ abstract class AbstractDatabaseResource extends BaseResource
return $results->getTotalResults();
}
return parent::count($query, $context);
return $query->toBase()->getCountForPagination();
}
/**
* @param FlarumContext $context
*/
public function find(string $id, Context $context): ?object
{
return $this->query($context)->find($id);
}
/**
* @param M $model
* @param FlarumContext $context
* @throws \Exception
*/
public function setValue(object $model, Field $field, mixed $value, Context $context): void
{
if ($field instanceof Relationship) {
$method = $this->method($field);
$relation = $model->$method();
// If this is a belongs-to relationship, then the ID is stored on the
// model itself, so we can set it here.
if ($relation instanceof BelongsTo) {
$relation->associate($value);
}
return;
}
// Mind-blowingly, Laravel discards timezone information when storing
// dates in the database. Since the API can receive dates in any
// timezone, we will need to convert it to the app's configured
// timezone ourselves before storage.
if (
$field instanceof Attribute &&
$field->type instanceof DateTime &&
$value instanceof \DateTimeInterface
) {
$value = \DateTime::createFromInterface($value)->setTimezone(
new \DateTimeZone(config('app.timezone')),
);
}
$model->setAttribute($this->property($field), $value);
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function saveValue(object $model, Field $field, mixed $value, Context $context): void
{
if ($field instanceof ToMany) {
$method = $this->method($field);
$relation = $model->$method();
if ($relation instanceof BelongsToMany) {
$relation->sync(new Collection($value));
}
}
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function create(object $model, Context $context): object
{
$this->saveModel($model, $context);
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function update(object $model, Context $context): object
{
$this->saveModel($model, $context);
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
protected function saveModel(Model $model, Context $context): void
{
$model->save();
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function delete(object $model, Context $context): void
{
$model->delete();
}
/**
* Get the model property that a field represents.
*/
protected function property(Field $field): string
{
return $field->property ?: Str::snake($field->name);
}
/**
* Get the model method that a field represents.
*/
protected function method(Field $field): string
{
return $field->property ?: $field->name;
}
/** @inheritDoc */
public function newModel(Context $context): object
{
return new ($this->model());
}
public function filters(): array
{
throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.');
}
/**
* @param FlarumContext $context
*/
public function mutateDataBeforeValidation(Context $context, array $data): array
{
return $data;
}
}

View File

@@ -12,16 +12,22 @@ 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\HasHooks;
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;
use HasHooks;
public function id(Context $context): ?string
{
return $context->extractIdFromPath($context);
}
}

View File

@@ -0,0 +1,189 @@
<?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\Context as FlarumContext;
use Flarum\Api\Resource\Contracts\Creatable;
use Flarum\Api\Resource\Contracts\Deletable;
use Flarum\Api\Resource\Contracts\Updatable;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\User;
use RuntimeException;
use Tobyz\JsonApiServer\Context;
/**
* @template M of object
*/
trait HasHooks
{
use DispatchEventsTrait {
dispatchEventsFor as traitDispatchEventsFor;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function createAction(object $model, Context $context): object
{
if (! $this instanceof Creatable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($this), Creatable::class),
);
}
$model = $this->creating($model, $context) ?: $model;
$model = $this->saving($model, $context) ?: $model;
$model = $this->create($model, $context);
$model = $this->saved($model, $context) ?: $model;
$model = $this->created($model, $context) ?: $model;
$this->dispatchEventsFor($model, $context->getActor());
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function updateAction(object $model, Context $context): object
{
if (! $this instanceof Updatable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($this), Updatable::class),
);
}
$model = $this->updating($model, $context) ?: $model;
$model = $this->saving($model, $context) ?: $model;
$this->update($model, $context);
$model = $this->saved($model, $context) ?: $model;
$model = $this->updated($model, $context) ?: $model;
$this->dispatchEventsFor($model, $context->getActor());
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function deleteAction(object $model, Context $context): void
{
if (! $this instanceof Deletable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($this), Deletable::class),
);
}
$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);
}
}
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Attachable as AttachableContract;
interface Attachable extends AttachableContract
{
//
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Context;
interface Countable extends Listable
{
/**
* Count the models for the given query.
*/
public function count(object $query, Context $context): ?int;
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Creatable as CreatableContract;
interface Creatable extends CreatableContract
{
//
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Deletable as DeletableContract;
interface Deletable extends DeletableContract
{
//
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Findable as FindableContract;
interface Findable extends FindableContract
{
//
}

View File

@@ -0,0 +1,54 @@
<?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\Contracts;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Filter;
use Tobyz\JsonApiServer\Schema\Sort;
/**
* @template M of object
* @template C of Context
*/
interface Listable
{
/**
* Create a query object for the current request.
*
* @param Context $context
*/
public function query(Context $context): object;
/**
* Get results from the given query.
*
* @param Context $context
*/
public function results(object $query, Context $context): iterable;
/**
* Filters that can be applied to the resource list.
*
* @return Filter[]
*/
public function filters(): array;
/**
* Sorts that can be applied to the resource list.
*
* @return Sort[]
*/
public function sorts(): array;
/**
* Resolve the sorts for this resource.
*/
public function resolveSorts(): array;
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Paginatable as PaginatableContract;
interface Paginatable extends PaginatableContract
{
//
}

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\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Updatable as UpdatableContract;
interface Updatable extends UpdatableContract
{
//
}

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