diff --git a/app/Http/Controllers/Articles/ArticlesController.php b/app/Http/Controllers/Articles/ArticlesController.php
index 89db0caa8..4af1e3247 100644
--- a/app/Http/Controllers/Articles/ArticlesController.php
+++ b/app/Http/Controllers/Articles/ArticlesController.php
@@ -6,6 +6,7 @@
use App\Http\Controllers\Controller;
use App\Http\Middleware\Authenticate;
use App\Http\Requests\ArticleRequest;
+use App\Http\Resources\ArticleResource;
use App\Jobs\CreateArticle;
use App\Jobs\DeleteArticle;
use App\Jobs\UpdateArticle;
@@ -17,6 +18,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
+use Symfony\Component\HttpFoundation\Response;
class ArticlesController extends Controller
{
@@ -101,11 +103,13 @@ public function create()
public function store(ArticleRequest $request)
{
- $article = $this->dispatchNow(CreateArticle::fromRequest($request));
+ $article = $this->dispatchSync(CreateArticle::fromRequest($request));
$this->success($request->shouldBeSubmitted() ? 'articles.submitted' : 'articles.created');
- return redirect()->route('articles.show', $article->slug());
+ return $request->wantsJson()
+ ? ArticleResource::make($article)
+ : redirect()->route('articles.show', $article->slug());
}
public function edit(Article $article)
@@ -125,7 +129,7 @@ public function update(ArticleRequest $request, Article $article)
$wasNotPreviouslySubmitted = $article->isNotSubmitted();
- $article = $this->dispatchNow(UpdateArticle::fromRequest($article, $request));
+ $article = $this->dispatchSync(UpdateArticle::fromRequest($article, $request));
if ($wasNotPreviouslySubmitted && $request->shouldBeSubmitted()) {
$this->success('articles.submitted');
@@ -133,17 +137,21 @@ public function update(ArticleRequest $request, Article $article)
$this->success('articles.updated');
}
- return redirect()->route('articles.show', $article->slug());
+ return $request->wantsJson()
+ ? ArticleResource::make($article)
+ : redirect()->route('articles.show', $article->slug());
}
- public function delete(Article $article)
+ public function delete(Request $request, Article $article)
{
$this->authorize(ArticlePolicy::DELETE, $article);
- $this->dispatchNow(new DeleteArticle($article));
+ $this->dispatchSync(new DeleteArticle($article));
$this->success('articles.deleted');
- return redirect()->route('articles');
+ return $request->wantsJson()
+ ? response()->json([], Response::HTTP_NO_CONTENT)
+ : redirect()->route('articles');
}
}
diff --git a/app/Http/Controllers/Settings/ApiTokenController.php b/app/Http/Controllers/Settings/ApiTokenController.php
new file mode 100644
index 000000000..41c4fc82e
--- /dev/null
+++ b/app/Http/Controllers/Settings/ApiTokenController.php
@@ -0,0 +1,33 @@
+middleware(Authenticate::class);
+ }
+
+ public function store(CreateApiTokenRequest $request)
+ {
+ $this->dispatchSync(new CreateApiToken(Auth::user(), $request->name()));
+
+ return redirect()->route('settings.profile');
+ }
+
+ public function destroy(DeleteApiTokenRequest $request)
+ {
+ $this->dispatchSync(new DeleteApiToken(Auth::user(), $request->id()));
+
+ return redirect()->route('settings.profile');
+ }
+}
diff --git a/app/Http/Requests/CreateApiTokenRequest.php b/app/Http/Requests/CreateApiTokenRequest.php
new file mode 100644
index 000000000..d22758d75
--- /dev/null
+++ b/app/Http/Requests/CreateApiTokenRequest.php
@@ -0,0 +1,20 @@
+ ['required', 'string', 'max:255'],
+ ];
+ }
+
+ public function name(): string
+ {
+ return (string) $this->get('name');
+ }
+}
diff --git a/app/Http/Requests/DeleteApiTokenRequest.php b/app/Http/Requests/DeleteApiTokenRequest.php
new file mode 100644
index 000000000..6bedf2630
--- /dev/null
+++ b/app/Http/Requests/DeleteApiTokenRequest.php
@@ -0,0 +1,20 @@
+ ['required', 'exists:personal_access_tokens,id'],
+ ];
+ }
+
+ public function id(): string
+ {
+ return (string) $this->get('id');
+ }
+}
diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php
new file mode 100644
index 000000000..65fecd054
--- /dev/null
+++ b/app/Http/Resources/ArticleResource.php
@@ -0,0 +1,28 @@
+ $this->getKey(),
+ 'url' => route('articles.show', $this->slug()),
+ 'title' => $this->title(),
+ 'body' => $this->body(),
+ 'original_url' => $this->originalUrl(),
+ 'author' => AuthorResource::make($this->author()),
+ 'tags' => TagResource::collection($this->tags()),
+ 'is_submitted' => $this->isSubmitted(),
+ 'submitted_at' => $this->submittedAt(),
+ 'created_at' => $this->createdAt(),
+ 'updated_at' => $this->updatedAt(),
+ ];
+ }
+}
diff --git a/app/Http/Resources/AuthorResource.php b/app/Http/Resources/AuthorResource.php
new file mode 100644
index 000000000..23a1e27e2
--- /dev/null
+++ b/app/Http/Resources/AuthorResource.php
@@ -0,0 +1,24 @@
+ $this->getKey(),
+ 'email' => $this->emailAddress(),
+ 'username' => $this->username(),
+ 'name' => $this->name(),
+ 'bio' => $this->bio(),
+ 'twitter_handle' => $this->twitter(),
+ 'github_username' => $this->githubUsername(),
+ ];
+ }
+}
diff --git a/app/Http/Resources/TagResource.php b/app/Http/Resources/TagResource.php
new file mode 100644
index 000000000..bd44f506b
--- /dev/null
+++ b/app/Http/Resources/TagResource.php
@@ -0,0 +1,20 @@
+ $this->getKey(),
+ 'name' => $this->name(),
+ 'slug' => $this->slug(),
+ ];
+ }
+}
diff --git a/app/Jobs/CreateApiToken.php b/app/Jobs/CreateApiToken.php
new file mode 100644
index 000000000..22b6b0712
--- /dev/null
+++ b/app/Jobs/CreateApiToken.php
@@ -0,0 +1,24 @@
+user->createToken($this->name);
+ }
+}
diff --git a/app/Jobs/DeleteApiToken.php b/app/Jobs/DeleteApiToken.php
new file mode 100644
index 000000000..4da916d16
--- /dev/null
+++ b/app/Jobs/DeleteApiToken.php
@@ -0,0 +1,24 @@
+user->tokens()->where('id', $this->tokenId)->delete();
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index 743faff14..58c66665b 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -10,9 +10,11 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Auth;
+use Laravel\Sanctum\HasApiTokens;
final class User extends Authenticatable implements MustVerifyEmail
{
+ use HasApiTokens;
use HasFactory;
use HasTimestamps;
use Notifiable;
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index 62fc9d3ea..7b314387c 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -4,6 +4,7 @@
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
@@ -49,7 +50,7 @@ public function boot()
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
- return Limit::perMinute(60);
+ return Limit::perMinute(6);
});
}
}
diff --git a/composer.json b/composer.json
index f37e7aa4d..5be8d7dee 100644
--- a/composer.json
+++ b/composer.json
@@ -21,6 +21,7 @@
"laravel-notification-channels/twitter": "^5.0",
"laravel/framework": "8.75.0",
"laravel/horizon": "^5.2",
+ "laravel/sanctum": "^2.13",
"laravel/slack-notification-channel": "^2.3",
"laravel/socialite": "^5.0",
"laravel/ui": "^3.0",
diff --git a/composer.lock b/composer.lock
index 1e54b0903..0ebbe3b0c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "9b314c81b08fdbcdb208b7cc8723abe6",
+ "content-hash": "ab818bb4925f4b7c34eb0449e5cce022",
"packages": [
{
"name": "abraham/twitteroauth",
@@ -2844,6 +2844,70 @@
},
"time": "2021-08-31T14:56:54+00:00"
},
+ {
+ "name": "laravel/sanctum",
+ "version": "v2.13.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/sanctum.git",
+ "reference": "b4c07d0014b78430a3c827064217f811f0708eaa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/b4c07d0014b78430a3c827064217f811f0708eaa",
+ "reference": "b4c07d0014b78430a3c827064217f811f0708eaa",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/contracts": "^6.9|^7.0|^8.0",
+ "illuminate/database": "^6.9|^7.0|^8.0",
+ "illuminate/support": "^6.9|^7.0|^8.0",
+ "php": "^7.2|^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0",
+ "orchestra/testbench": "^4.0|^5.0|^6.0",
+ "phpunit/phpunit": "^8.0|^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ },
+ "laravel": {
+ "providers": [
+ "Laravel\\Sanctum\\SanctumServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Sanctum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
+ "keywords": [
+ "auth",
+ "laravel",
+ "sanctum"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/sanctum/issues",
+ "source": "https://github.com/laravel/sanctum"
+ },
+ "time": "2021-12-14T17:49:47+00:00"
+ },
{
"name": "laravel/scout",
"version": "v9.2.8",
diff --git a/config/sanctum.php b/config/sanctum.php
new file mode 100644
index 000000000..e7d6cf174
--- /dev/null
+++ b/config/sanctum.php
@@ -0,0 +1,65 @@
+ explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
+ '%s%s',
+ 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
+ env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '',
+ ))),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Sanctum Guards
+ |--------------------------------------------------------------------------
+ |
+ | This array contains the authentication guards that will be checked when
+ | Sanctum is trying to authenticate a request. If none of these guards
+ | are able to authenticate the request, Sanctum will use the bearer
+ | token that's present on an incoming request for authentication.
+ |
+ */
+
+ 'guard' => ['web'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Expiration Minutes
+ |--------------------------------------------------------------------------
+ |
+ | This value controls the number of minutes until an issued token will be
+ | considered expired. If this value is null, personal access tokens do
+ | not expire. This won't tweak the lifetime of first-party sessions.
+ |
+ */
+
+ 'expiration' => null,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Sanctum Middleware
+ |--------------------------------------------------------------------------
+ |
+ | When authenticating your first-party SPA with Sanctum you may need to
+ | customize some of the middleware Sanctum uses while processing the
+ | request. You may change the middleware listed below as required.
+ |
+ */
+
+ 'middleware' => [
+ 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
+ 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
+ ],
+
+];
diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php
new file mode 100644
index 000000000..2bcbffaed
--- /dev/null
+++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php
@@ -0,0 +1,21 @@
+bigIncrements('id');
+ $table->morphs('tokenable');
+ $table->string('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ });
+ }
+}
diff --git a/database/schema/mysql-schema.dump b/database/schema/mysql-schema.dump
index 5a8e23ec6..52d6d2cb4 100644
--- a/database/schema/mysql-schema.dump
+++ b/database/schema/mysql-schema.dump
@@ -130,6 +130,24 @@ CREATE TABLE `password_resets` (
KEY `password_resets_email_index` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
+DROP TABLE IF EXISTS `personal_access_tokens`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `personal_access_tokens` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `tokenable_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `tokenable_id` bigint unsigned NOT NULL,
+ `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `abilities` text COLLATE utf8mb4_unicode_ci,
+ `last_used_at` timestamp NULL DEFAULT NULL,
+ `created_at` timestamp NULL DEFAULT NULL,
+ `updated_at` timestamp NULL DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `personal_access_tokens_token_unique` (`token`),
+ KEY `personal_access_tokens_tokenable_type_tokenable_id_index` (`tokenable_type`,`tokenable_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `replies`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
@@ -323,3 +341,4 @@ INSERT INTO `migrations` VALUES (63,'2021_09_12_220452_add_resolved_by_to_thread
INSERT INTO `migrations` VALUES (64,'2021_10_31_143501_add_declined_at_column_to_articles_table',3);
INSERT INTO `migrations` VALUES (65,'2021_11_15_213258_add_updated_by_to_threads_and_replies_table',4);
INSERT INTO `migrations` VALUES (66,'2021_11_22_093555_migrate_thread_feed_to_timestamp',4);
+INSERT INTO `migrations` VALUES (67,'2019_12_14_000001_create_personal_access_tokens_table',5);
diff --git a/resources/views/users/settings/api_tokens.blade.php b/resources/views/users/settings/api_tokens.blade.php
new file mode 100644
index 000000000..f6a676051
--- /dev/null
+++ b/resources/views/users/settings/api_tokens.blade.php
@@ -0,0 +1,50 @@
+@title('API Tokens')
+
+
+ Create API tokens to access your account over our REST API.
+
+ API Tokens
+
+
+ @foreach(Auth::user()->tokens as $token)
+
+
+