diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..61513f9a
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,23 @@
+name: tests
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ laravel-13:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.3"
+ extensions: curl, mbstring, sqlite, xml, zip
+ coverage: none
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Run tests
+ run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
index 41fe538b..7b7712ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
/node_modules
/vendor
/.idea
+/.phpunit.cache
composer.lock
npm-debug.log
yarn-error.log
diff --git a/UPGRADE.md b/UPGRADE.md
index 48425dee..d00bf872 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -12,3 +12,28 @@ php artisan vendor:publish --tag=wink-assets --force
```
In addition to this, make sure you render your posts and pages using `$post->content` and `$page->content` instead of `->body`.
+
+## Laravel 11, 12, and 13
+
+This release requires PHP 8.3+ and Laravel 11, 12, or 13.
+
+`Wink\WinkAuthor` implements `getAuthPasswordName()` so the Laravel 11 authentication contract can resolve the password column. Route files import the `Route` facade directly, because a fresh Laravel 11+ application does not register global facade aliases.
+
+### Published configuration
+
+Laravel 11 and newer store the `local` filesystem disk in `storage/app/private`. Wink now defaults to the `public` disk so `php artisan storage:link` can serve uploads. If you already published `config/wink.php`, update these values:
+
+```php
+'storage_disk' => env('WINK_STORAGE_DISK', 'public'),
+
+'storage_path' => env('WINK_STORAGE_PATH', 'wink/images'),
+```
+
+Files uploaded with the previous defaults on Laravel 10 (`local` disk, path `public/wink/images`) already live in `storage/app/public/wink/images`, which is the same directory as the new defaults. If a Laravel 11+ app stored uploads under `storage/app/private/public/wink/images`, move that directory to `storage/app/public/wink/images`.
+
+After updating, run:
+
+```sh
+php artisan wink:migrate
+php artisan vendor:publish --tag=wink-assets --force
+```
diff --git a/composer.json b/composer.json
index c10f4256..02c7f6c1 100644
--- a/composer.json
+++ b/composer.json
@@ -10,14 +10,24 @@
}
],
"require": {
- "php": "^7.3||^7.4||^8.0",
- "laravel/framework": "^7.0||^8.0||^9.0|^10.0"
+ "php": "^8.3",
+ "laravel/framework": "^11.0||^12.0||^13.0",
+ "league/commonmark": "^2.7"
+ },
+ "require-dev": {
+ "orchestra/testbench": "^11.0",
+ "phpunit/phpunit": "^11.5.50||^12.5.8"
},
"autoload": {
"psr-4": {
"Wink\\": "src/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Wink\\Tests\\": "tests/"
+ }
+ },
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
@@ -28,6 +38,9 @@
]
}
},
+ "scripts": {
+ "test": "phpunit"
+ },
"config": {
"sort-packages": true
},
diff --git a/config/wink.php b/config/wink.php
index b75fbd6b..720a7b95 100644
--- a/config/wink.php
+++ b/config/wink.php
@@ -24,11 +24,15 @@
| any of the disks defined in your config/filesystems.php file. You may
| also configure the path where the files should be stored.
|
+ | The public disk keeps uploads under storage/app/public so that
+ | `php artisan storage:link` can serve them. Laravel 11 and newer point
+ | the local disk at storage/app/private, which is not web-accessible.
+ |
*/
- 'storage_disk' => env('WINK_STORAGE_DISK', 'local'),
+ 'storage_disk' => env('WINK_STORAGE_DISK', 'public'),
- 'storage_path' => env('WINK_STORAGE_PATH', 'public/wink/images'),
+ 'storage_path' => env('WINK_STORAGE_PATH', 'wink/images'),
/*
|--------------------------------------------------------------------------
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 00000000..ab4a7bcf
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,16 @@
+
+
+
+
+ tests
+
+
+
+
+
+
diff --git a/readme.md b/readme.md
index 5d4bb4cf..f2b9256d 100644
--- a/readme.md
+++ b/readme.md
@@ -8,14 +8,28 @@ Wink is used to manage the [official Laravel blog](https://blog.laravel.com), [d
Dark & Light modes available so everyone is happy 😁
+## Requirements
+
+- PHP 8.3, 8.4, or 8.5
+- Laravel 11, 12, or 13
+
## Installation
Wink uses a separate database connection and authentication system so that you don't have to modify any of your project code.
-To install Wink, run these commands in the root of your Laravel app:
+Packagist `themsaid/wink` stops at Laravel 10. Install this fork from GitHub:
+
+```json
+"repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/drscript/wink"
+ }
+]
+```
```sh
-composer require themsaid/wink
+composer require themsaid/wink:dev-1.x
php artisan wink:install
php artisan storage:link
```
@@ -28,6 +42,8 @@ php artisan wink:migrate
Head to `yourproject.test/wink` and use the provided email and password to log in.
+Uploaded images use the `public` disk and are stored in `storage/app/public/wink/images`. `storage:link` serves that directory. On Laravel 11 and newer the `local` disk points at `storage/app/private`, so leave `storage_disk` set to `public` unless you intend to use a private or S3 disk.
+
## Uploading to S3
If you want to upload images to S3, update the `storage_disk` attribute in your `wink.php` configuration file to s3. Make sure your S3 disk is correctly configured in your `filesystems.php` configuration file.
diff --git a/src/Console/MigrateCommand.php b/src/Console/MigrateCommand.php
index 62a23093..cd48e509 100644
--- a/src/Console/MigrateCommand.php
+++ b/src/Console/MigrateCommand.php
@@ -38,7 +38,8 @@ public function handle()
$this->call('migrate', [
'--database' => config('wink.database_connection'),
- '--path' => 'vendor/themsaid/wink/src/Migrations',
+ '--path' => realpath(__DIR__.'/../Migrations'),
+ '--realpath' => true,
'--force' => $this->option('force') ?? true,
]);
diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php
index cb87b2a0..a93b5977 100644
--- a/src/Http/Controllers/ForgotPasswordController.php
+++ b/src/Http/Controllers/ForgotPasswordController.php
@@ -3,6 +3,7 @@
namespace Wink\Http\Controllers;
use Illuminate\Routing\Controller;
+use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;
@@ -69,7 +70,7 @@ public function showNewPassword($token)
cache()->forget('password.reset.'.$authorId);
- $author->password = \Hash::make($password = Str::random());
+ $author->password = Hash::make($password = Str::random());
$author->save();
diff --git a/src/Http/routes.php b/src/Http/routes.php
index d9cae4ca..9deb3a12 100644
--- a/src/Http/routes.php
+++ b/src/Http/routes.php
@@ -1,5 +1,6 @@
{$this->getAuthIdentifierName()};
}
+ /**
+ * Get the name of the password attribute for the user.
+ *
+ * @return string
+ */
+ public function getAuthPasswordName()
+ {
+ return 'password';
+ }
+
/**
* Get the password for the user.
*
diff --git a/src/WinkPost.php b/src/WinkPost.php
index 161d0c14..c7011ed6 100644
--- a/src/WinkPost.php
+++ b/src/WinkPost.php
@@ -111,7 +111,7 @@ public function getContentAttribute()
'allow_unsafe_links' => false,
]);
- return new HtmlString($converter->convertToHtml($this->body));
+ return new HtmlString((string) $converter->convert((string) $this->body));
}
/**
diff --git a/src/WinkServiceProvider.php b/src/WinkServiceProvider.php
index d9588c27..e1b05a02 100644
--- a/src/WinkServiceProvider.php
+++ b/src/WinkServiceProvider.php
@@ -35,26 +35,40 @@ private function registerRoutes()
{
$middlewareGroup = config('wink.middleware_group');
- Route::middleware($middlewareGroup)
- ->as('wink.')
- ->domain(config('wink.domain'))
- ->prefix(config('wink.path'))
- ->group(function () {
- Route::get('/login', [LoginController::class, 'showLoginForm'])->name('auth.login');
- Route::post('/login', [LoginController::class, 'login'])->name('auth.attempt');
-
- Route::get('/password/forgot', [ForgotPasswordController::class, 'showResetRequestForm'])->name('password.forgot');
- Route::post('/password/forgot', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
- Route::get('/password/reset/{token}', [ForgotPasswordController::class, 'showNewPassword'])->name('password.reset');
- });
-
- Route::middleware([$middlewareGroup, Authenticate::class])
+ $this->routeGroup($middlewareGroup)->group(function () {
+ Route::get('/login', [LoginController::class, 'showLoginForm'])->name('auth.login');
+ Route::post('/login', [LoginController::class, 'login'])->name('auth.attempt');
+
+ Route::get('/password/forgot', [ForgotPasswordController::class, 'showResetRequestForm'])->name('password.forgot');
+ Route::post('/password/forgot', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
+ Route::get('/password/reset/{token}', [ForgotPasswordController::class, 'showNewPassword'])->name('password.reset');
+ });
+
+ $this->routeGroup([$middlewareGroup, Authenticate::class])->group(function () {
+ $this->loadRoutesFrom(__DIR__.'/Http/routes.php');
+ });
+ }
+
+ /**
+ * Start a Wink route group.
+ *
+ * An empty domain is omitted so routes stay host-agnostic. Laravel 13
+ * prioritizes routes that declare a domain during matching.
+ *
+ * @param string|array $middleware
+ * @return \Illuminate\Routing\RouteRegistrar
+ */
+ private function routeGroup($middleware)
+ {
+ $routes = Route::middleware($middleware)
->as('wink.')
- ->domain(config('wink.domain'))
- ->prefix(config('wink.path'))
- ->group(function () {
- $this->loadRoutesFrom(__DIR__.'/Http/routes.php');
- });
+ ->prefix(config('wink.path'));
+
+ if ($domain = config('wink.domain')) {
+ $routes->domain($domain);
+ }
+
+ return $routes;
}
/**
diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php
new file mode 100644
index 00000000..4213628c
--- /dev/null
+++ b/tests/AuthenticationTest.php
@@ -0,0 +1,71 @@
+get('/wink')->assertRedirect(route('wink.auth.login'));
+ $this->get('/wink/login')->assertOk()->assertSee('Log In');
+ $this->getJson('/wink/api/posts')->assertUnauthorized();
+ }
+
+ public function test_authors_can_log_in_and_out(): void
+ {
+ $this->author();
+
+ $this->post('/wink/login', [
+ 'email' => 'admin@mail.com',
+ 'password' => 'secret',
+ ])->assertRedirect('/wink');
+
+ $this->get('/wink')->assertOk()->assertSee('Regina Phalange', false);
+
+ $this->get('/wink/logout')->assertRedirect(route('wink.auth.login'));
+ $this->get('/wink/api/posts')->assertRedirect(route('wink.auth.login'));
+ }
+
+ public function test_invalid_credentials_are_rejected(): void
+ {
+ $this->author();
+
+ $this->from('/wink/login')->post('/wink/login', [
+ 'email' => 'admin@mail.com',
+ 'password' => 'wrong',
+ ])->assertRedirect('/wink/login')->assertSessionHasErrors('email');
+ }
+
+ public function test_password_reset_issues_a_new_password(): void
+ {
+ $author = $this->author();
+
+ Mail::fake();
+
+ $this->post('/wink/password/forgot', [
+ 'email' => $author->email,
+ ])->assertRedirect(route('wink.password.forgot'));
+
+ $token = null;
+
+ Mail::assertSent(ResetPasswordEmail::class, function (ResetPasswordEmail $mail) use (&$token) {
+ $token = $mail->token;
+
+ return $mail->hasTo('admin@mail.com');
+ });
+
+ $this->get('/wink/password/reset/'.$token)
+ ->assertOk()
+ ->assertSee('Copy your new password');
+
+ $author->refresh();
+
+ $this->assertFalse(\Illuminate\Support\Facades\Hash::check('secret', $author->password));
+
+ $this->get('/wink/password/reset/'.$token)
+ ->assertRedirect(route('wink.password.forgot'));
+ }
+}
diff --git a/tests/DomainRoutingTest.php b/tests/DomainRoutingTest.php
new file mode 100644
index 00000000..7f6d22dd
--- /dev/null
+++ b/tests/DomainRoutingTest.php
@@ -0,0 +1,22 @@
+set('wink.domain', 'blog.test');
+ }
+
+ public function test_routes_are_limited_to_the_configured_domain(): void
+ {
+ $login = app('router')->getRoutes()->getByName('wink.auth.login');
+ $posts = app('router')->getRoutes()->getByName('wink.posts.index');
+
+ $this->assertSame('blog.test', $login->getDomain());
+ $this->assertSame('blog.test', $posts->getDomain());
+ }
+}
diff --git a/tests/PackageTest.php b/tests/PackageTest.php
new file mode 100644
index 00000000..6f38b1c8
--- /dev/null
+++ b/tests/PackageTest.php
@@ -0,0 +1,56 @@
+assertSame(WinkAuthor::class, config('auth.providers.wink_authors.model'));
+ $this->assertSame('wink_authors', config('auth.guards.wink.provider'));
+
+ $login = app('router')->getRoutes()->getByName('wink.auth.login');
+
+ $this->assertNotNull($login);
+ $this->assertNull($login->getDomain());
+ $this->assertSame('wink/login', $login->uri());
+ $this->assertNotNull(app('router')->getRoutes()->getByName('wink.posts.index'));
+ }
+
+ public function test_migrations_create_wink_tables_on_the_wink_connection(): void
+ {
+ $this->assertTrue(\Illuminate\Support\Facades\Schema::connection('wink')->hasTable('wink_posts'));
+ $this->assertTrue(\Illuminate\Support\Facades\Schema::connection('wink')->hasTable('wink_authors'));
+ $this->assertTrue(\Illuminate\Support\Facades\Schema::connection('wink')->hasTable('wink_tags'));
+ $this->assertTrue(\Illuminate\Support\Facades\Schema::connection('wink')->hasTable('wink_pages'));
+ $this->assertFalse(\Illuminate\Support\Facades\Schema::connection('sqlite')->hasTable('wink_posts'));
+ }
+
+ public function test_install_command_publishes_assets_and_config(): void
+ {
+ $this->artisan('wink:install')->assertSuccessful();
+
+ $this->assertFileExists(public_path('vendor/wink/mix-manifest.json'));
+ $this->assertFileExists(config_path('wink.php'));
+ }
+
+ public function test_migrate_command_creates_the_default_author(): void
+ {
+ $this->artisan('wink:migrate')
+ ->expectsOutputToContain('admin@mail.com')
+ ->assertSuccessful();
+
+ $author = WinkAuthor::first();
+
+ $this->assertNotNull($author);
+ $this->assertSame('admin@mail.com', $author->email);
+ $this->assertSame('password', $author->getAuthPasswordName());
+ $this->assertTrue(Hash::isHashed($author->password));
+
+ $this->artisan('wink:migrate')->assertSuccessful();
+ $this->assertSame(1, WinkAuthor::count());
+ }
+}
diff --git a/tests/PublishingTest.php b/tests/PublishingTest.php
new file mode 100644
index 00000000..4a0f22ae
--- /dev/null
+++ b/tests/PublishingTest.php
@@ -0,0 +1,150 @@
+author();
+
+ $live = $this->postFor($author, [
+ 'markdown' => true,
+ 'body' => '**Hello**',
+ 'published' => true,
+ 'publish_date' => now()->subDay(),
+ ]);
+ $draft = $this->postFor($author, [
+ 'id' => (string) Str::uuid(),
+ 'slug' => 'draft',
+ 'published' => false,
+ 'publish_date' => now()->subDay(),
+ ]);
+ $scheduled = $this->postFor($author, [
+ 'id' => (string) Str::uuid(),
+ 'slug' => 'scheduled',
+ 'published' => true,
+ 'publish_date' => now()->addDay(),
+ ]);
+
+ $tag = WinkTag::create([
+ 'id' => (string) Str::uuid(),
+ 'slug' => 'laravel',
+ 'name' => 'Laravel',
+ ]);
+ $live->tags()->attach($tag->id);
+
+ $this->assertStringContainsString('Hello', (string) $live->content);
+ $this->assertSame('The body', $draft->content);
+ $this->assertSame([$live->id], WinkPost::live()->pluck('id')->all());
+ $this->assertSame([$draft->id], WinkPost::draft()->pluck('id')->all());
+ $this->assertSame([$scheduled->id], WinkPost::scheduled()->pluck('id')->all());
+ $this->assertTrue(WinkPost::tag('laravel')->whereKey($live->id)->exists());
+ }
+
+ public function test_authors_can_manage_posts_pages_tags_and_team_members(): void
+ {
+ $author = $this->author();
+ $this->actingAs($author, 'wink');
+
+ $this->postJson('/wink/api/posts/new', [
+ 'id' => $postId = (string) Str::uuid(),
+ 'title' => 'First post',
+ 'slug' => 'first-post',
+ 'excerpt' => 'Excerpt',
+ 'body' => 'Body',
+ 'published' => true,
+ 'markdown' => false,
+ 'author_id' => $author->id,
+ 'publish_date' => now()->format('Y-m-d H:i:s'),
+ 'tags' => [
+ ['id' => (string) Str::uuid(), 'name' => 'News'],
+ ],
+ ])->assertOk()->assertJsonPath('entry.slug', 'first-post');
+
+ $this->assertTrue(WinkPost::find($postId)->tags->contains('name', 'News'));
+
+ $this->postJson('/wink/api/posts/new', [
+ 'id' => (string) Str::uuid(),
+ 'title' => 'Duplicate',
+ 'slug' => 'first-post',
+ 'author_id' => $author->id,
+ 'publish_date' => now()->format('Y-m-d H:i:s'),
+ ])->assertStatus(422);
+
+ $this->getJson('/wink/api/posts?status=published&author_id='.$author->id)
+ ->assertOk()
+ ->assertJsonPath('data.0.slug', 'first-post');
+
+ $this->postJson('/wink/api/pages/new', [
+ 'id' => $pageId = (string) Str::uuid(),
+ 'title' => 'About',
+ 'slug' => 'about',
+ 'body' => 'About this site',
+ ])->assertOk();
+
+ $this->assertSame('About this site', WinkPage::find($pageId)->content);
+
+ $this->postJson('/wink/api/tags/new', [
+ 'id' => $tagId = (string) Str::uuid(),
+ 'name' => 'Laravel',
+ 'slug' => 'laravel',
+ ])->assertOk();
+
+ $this->deleteJson('/wink/api/tags/'.$tagId)->assertOk();
+ $this->assertNull(WinkTag::find($tagId));
+
+ $teammateId = (string) Str::uuid();
+
+ $this->postJson('/wink/api/team/new', [
+ 'id' => $teammateId,
+ 'name' => 'Ada Lovelace',
+ 'slug' => 'ada-lovelace',
+ 'email' => 'ada@example.com',
+ 'password' => 'secret-pass',
+ 'bio' => 'Author',
+ ])->assertOk();
+
+ $this->deleteJson('/wink/api/team/'.$author->id)
+ ->assertStatus(402)
+ ->assertJsonPath('message', 'Please remove the author\'s posts first.');
+
+ $this->deleteJson('/wink/api/posts/'.$postId)->assertOk();
+
+ $this->deleteJson('/wink/api/team/'.$author->id)
+ ->assertStatus(402)
+ ->assertJsonPath('message', 'You cannot delete yourself.');
+
+ $this->deleteJson('/wink/api/team/'.$teammateId)->assertOk();
+ $this->assertNull(\Wink\WinkAuthor::find($teammateId));
+ }
+
+ public function test_image_uploads_are_stored_on_the_public_disk(): void
+ {
+ Storage::fake('public');
+
+ $this->actingAs($this->author(), 'wink');
+
+ $response = $this->postJson('/wink/api/uploads', [
+ 'image' => UploadedFile::fake()->create('photo.jpg', 20, 'image/jpeg'),
+ ]);
+
+ $response->assertOk();
+
+ $url = $response->json('url');
+
+ $this->assertIsString($url);
+ $this->assertStringContainsString('wink/images', $url);
+
+ $stored = Storage::disk('public')->allFiles('wink/images');
+
+ $this->assertCount(1, $stored);
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 00000000..9e8dd997
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,93 @@
+artisan('vendor:publish', [
+ '--tag' => 'wink-assets',
+ '--force' => true,
+ ]);
+ }
+
+ $this->artisan('migrate', [
+ '--database' => 'wink',
+ '--path' => realpath(__DIR__.'/../src/Migrations'),
+ '--realpath' => true,
+ ]);
+ }
+
+ protected function getPackageProviders($app)
+ {
+ return [
+ WinkServiceProvider::class,
+ ];
+ }
+
+ protected function defineEnvironment($app)
+ {
+ $app['config']->set('app.key', 'base64:YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=');
+ $app['config']->set('database.default', 'sqlite');
+ $app['config']->set('database.connections.sqlite', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]);
+ $app['config']->set('database.connections.wink', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]);
+ $app['config']->set('wink.database_connection', 'wink');
+ $app['config']->set('wink.storage_disk', 'public');
+ $app['config']->set('wink.storage_path', 'wink/images');
+ $app['config']->set('cache.default', 'array');
+ $app['config']->set('session.driver', 'array');
+ $app['config']->set('filesystems.disks.public', [
+ 'driver' => 'local',
+ 'root' => storage_path('framework/testing/disks/public'),
+ 'url' => 'http://localhost/storage',
+ 'visibility' => 'public',
+ ]);
+ }
+
+ protected function author(array $overrides = []): WinkAuthor
+ {
+ return WinkAuthor::create(array_merge([
+ 'id' => (string) Str::uuid(),
+ 'name' => 'Regina Phalange',
+ 'slug' => 'regina-phalange',
+ 'email' => 'admin@mail.com',
+ 'password' => Hash::make('secret'),
+ 'bio' => 'This is me.',
+ ], $overrides));
+ }
+
+ protected function postFor(WinkAuthor $author, array $overrides = []): WinkPost
+ {
+ return WinkPost::create(array_merge([
+ 'id' => (string) Str::uuid(),
+ 'slug' => 'hello-world',
+ 'title' => 'Hello World',
+ 'excerpt' => 'An excerpt',
+ 'body' => 'The body',
+ 'published' => true,
+ 'markdown' => false,
+ 'publish_date' => now()->subMinute(),
+ 'featured_image_caption' => '',
+ 'author_id' => $author->id,
+ ], $overrides));
+ }
+}