├── public ├── favicon.ico ├── robots.txt ├── mix-manifest.json ├── fonts │ └── vendor │ │ └── font-awesome │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 ├── .htaccess └── index.php ├── bootstrap ├── cache │ └── .gitignore └── app.php ├── storage ├── logs │ └── .gitignore ├── app │ ├── public │ │ └── .gitignore │ └── .gitignore ├── debugbar │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── database ├── .gitignore ├── factories │ ├── ActivityFactory.php │ ├── ChannelFactory.php │ ├── FavoriteFactory.php │ ├── ReplyFactory.php │ ├── DatabaseNotificationFactory.php │ ├── ThreadFactory.php │ └── UserFactory.php ├── seeds │ ├── ActivitySeeder.php │ ├── ChannelSeeder.php │ ├── FavoriteSeeder.php │ ├── UserSeeder.php │ ├── ReplySeeder.php │ ├── DatabaseSeeder.php │ └── ThreadSeeder.php └── migrations │ ├── 2020_03_31_120000_create_channels_table.php │ ├── 2014_10_12_100000_create_password_resets_table.php │ ├── 2019_08_19_000000_create_failed_jobs_table.php │ ├── 2020_04_05_174520_create_notifications_table.php │ ├── 2020_04_02_123700_create_activities_table.php │ ├── 2020_04_01_181057_create_favorites_table.php │ ├── 2020_04_14_160518_add_foreign_key_to_threads_table.php │ ├── 2014_10_12_000000_create_users_table.php │ ├── 2020_03_31_125700_create_replies_table.php │ ├── 2020_04_05_163901_create_thread_subscriptions_table.php │ └── 2020_03_31_125699_create_threads_table.php ├── .gitattributes ├── app ├── Exceptions │ ├── ThrottleException.php │ └── Handler.php ├── Http │ ├── Middleware │ │ ├── EncryptCookies.php │ │ ├── VerifyCsrfToken.php │ │ ├── CheckForMaintenanceMode.php │ │ ├── TrimStrings.php │ │ ├── TrustProxies.php │ │ ├── Authenticate.php │ │ ├── Administrator.php │ │ └── RedirectIfAuthenticated.php │ ├── Controllers │ │ ├── Controller.php │ │ ├── Api │ │ │ ├── UserController.php │ │ │ └── UserAvatarController.php │ │ ├── HomeController.php │ │ ├── SearchController.php │ │ ├── Auth │ │ │ ├── ForgotPasswordController.php │ │ │ ├── ResetPasswordController.php │ │ │ ├── LoginController.php │ │ │ ├── ConfirmPasswordController.php │ │ │ ├── VerificationController.php │ │ │ └── RegisterController.php │ │ ├── LockThreadController.php │ │ ├── ChannelController.php │ │ ├── FavoriteController.php │ │ ├── ActivityController.php │ │ ├── UserNotificationController.php │ │ ├── ProfileController.php │ │ ├── BestReplyController.php │ │ ├── SubscriptionController.php │ │ └── ReplyController.php │ ├── Requests │ │ └── CreatePostRequest.php │ └── Kernel.php ├── Inspections │ ├── KeyHeldDown.php │ ├── Spam.php │ └── InvalidKeywords.php ├── Providers │ ├── BroadcastServiceProvider.php │ ├── AppServiceProvider.php │ ├── AuthServiceProvider.php │ ├── EventServiceProvider.php │ └── RouteServiceProvider.php ├── Listeners │ ├── NotifyThreadSubscribers.php │ └── NotifyMentionedUsers.php ├── Filters │ ├── ThreadFilters.php │ └── Filters.php ├── Rules │ ├── SpamFree.php │ └── Recaptcha.php ├── Console │ └── Kernel.php ├── Events │ └── ThreadReceivedNewReply.php ├── Visits.php ├── Trending.php ├── RecordsActivity.php ├── Channel.php ├── Favorable.php ├── Favorite.php ├── Notifications │ ├── YouWereMentioned.php │ └── ThreadWasUpdated.php ├── Activity.php ├── Policies │ ├── UserPolicy.php │ ├── ThreadPolicy.php │ └── ReplyPolicy.php └── ThreadSubscription.php ├── .styleci.yml ├── resources ├── sass │ ├── app.scss │ └── _variables.scss ├── js │ ├── authorization.js │ ├── components │ │ ├── Search.vue │ │ ├── ImageUpload.vue │ │ ├── SubscribeButton.vue │ │ ├── WYSIWYG.vue │ │ ├── Flash.vue │ │ ├── UserNotifications.vue │ │ ├── Favorite.vue │ │ ├── AvatarForm.vue │ │ ├── Paginator.vue │ │ ├── Replies.vue │ │ └── NewReply.vue │ ├── mixins │ │ └── collection.js │ ├── bootstrap.js │ ├── pages │ │ └── Thread.vue │ └── app.js ├── views │ ├── profiles │ │ ├── activities │ │ │ ├── created_thread.blade.php │ │ │ ├── created_reply.blade.php │ │ │ └── created_favorite.blade.php │ │ └── show.blade.php │ ├── components │ │ └── activity.blade.php │ ├── home.blade.php │ ├── auth │ │ ├── verify.blade.php │ │ └── passwords │ │ │ ├── email.blade.php │ │ │ └── confirm.blade.php │ ├── threads │ │ ├── show.blade.php │ │ ├── _list.blade.php │ │ ├── _question.blade.php │ │ ├── index.blade.php │ │ ├── search.blade.php │ │ └── create.blade.php │ └── layouts │ │ └── app.blade.php └── lang │ └── en │ ├── pagination.php │ ├── auth.php │ └── passwords.php ├── .gitignore ├── .editorconfig ├── tests ├── utilities │ └── functions.php ├── TestCase.php ├── CreatesApplication.php ├── Unit │ ├── ChannelTest.php │ ├── SpamTest.php │ ├── UserTest.php │ └── ReplyTest.php └── Feature │ ├── ProfileTest.php │ ├── SearchTest.php │ ├── SubscribeToThreadsTest.php │ ├── TrendingThreadsTest.php │ ├── MentionUserTest.php │ ├── AddAvatarTest.php │ ├── FavoriteTest.php │ ├── UpdateThreadTest.php │ ├── TriggerActivityTest.php │ ├── BestReplyTest.php │ ├── NotificationTest.php │ └── LockThreadTest.php ├── Homestead.yaml ├── webpack.mix.js ├── routes ├── channels.php ├── api.php ├── console.php └── web.php ├── server.php ├── after.sh ├── config ├── cors.php ├── services.php ├── view.php ├── hashing.php ├── broadcasting.php ├── filesystems.php └── queue.php ├── .env.example ├── phpunit.xml ├── README.md ├── package.json ├── artisan ├── Vagrantfile └── composer.json /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | *.sqlite-journal 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/debugbar/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /public/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/app.js": "/js/app.js", 3 | "/css/app.css": "/css/app.css" 4 | } 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.scss linguist-vendored 4 | *.js linguist-vendored 5 | CHANGELOG.md export-ignore 6 | -------------------------------------------------------------------------------- /public/fonts/vendor/font-awesome/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keizah7/forum/HEAD/public/fonts/vendor/font-awesome/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/vendor/font-awesome/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keizah7/forum/HEAD/public/fonts/vendor/font-awesome/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/vendor/font-awesome/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keizah7/forum/HEAD/public/fonts/vendor/font-awesome/fontawesome-webfont.woff -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | routes.php 3 | schedule-* 4 | compiled.php 5 | services.json 6 | events.scanned.php 7 | routes.scanned.php 8 | down 9 | -------------------------------------------------------------------------------- /public/fonts/vendor/font-awesome/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keizah7/forum/HEAD/public/fonts/vendor/font-awesome/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /app/Exceptions/ThrottleException.php: -------------------------------------------------------------------------------- 1 | define(Activity::class, function (Faker $faker) { 9 | return [ 10 | // 11 | ]; 12 | }); 13 | -------------------------------------------------------------------------------- /database/seeds/ActivitySeeder.php: -------------------------------------------------------------------------------- 1 | create($attributes); 5 | } 6 | 7 | 8 | function make($class, $attributes = [], $times = null) { 9 | return factory($class, $times)->make($attributes); 10 | } 11 | -------------------------------------------------------------------------------- /database/seeds/UserSeeder.php: -------------------------------------------------------------------------------- 1 | create(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /database/seeds/ReplySeeder.php: -------------------------------------------------------------------------------- 1 | create(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /resources/views/profiles/activities/created_thread.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ $user->name }} published 4 | {{ $activity->subject->title }} 5 | 6 | 7 | {{ $activity->subject->body }} 8 | 9 | 10 | -------------------------------------------------------------------------------- /database/factories/ChannelFactory.php: -------------------------------------------------------------------------------- 1 | define(Channel::class, function (Faker $faker) { 9 | $name = $faker->word; 10 | 11 | return [ 12 | 'name' => $name, 13 | 'slug' => Str::slug($name), 14 | ]; 15 | }); 16 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | 4 |
5 | {{ $heading }} 6 | {{ $activity->created_at->diffForHumans() }} 7 |
8 | 9 |
10 |
{{ $body }}
11 |
12 | 13 | -------------------------------------------------------------------------------- /resources/views/profiles/activities/created_reply.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ $user->name }} replied to 4 | {{ $activity->subject->thread->title }} 5 | 6 | 7 | {{ $activity->subject->body }} 8 | 9 | 10 | -------------------------------------------------------------------------------- /database/seeds/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call([ 15 | UserSeeder::class, 16 | ThreadSeeder::class, 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Http/Middleware/VerifyCsrfToken.php: -------------------------------------------------------------------------------- 1 | 2 | import algoliasearch from 'algoliasearch/lite'; 3 | 4 | export default { 5 | name: "Search", 6 | data() { 7 | return { 8 | searchClient: algoliasearch( 9 | 'RIPX81I70I', 10 | 'a9c0912e0b5f4c8cfd9cc1fba5c17090' 11 | ), 12 | }; 13 | }, 14 | } 15 | 16 | -------------------------------------------------------------------------------- /resources/js/mixins/collection.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data () { 3 | return { 4 | items: [] 5 | }; 6 | }, 7 | methods: { 8 | add(item) { 9 | this.items.push(item); 10 | 11 | this.$emit('added'); 12 | }, 13 | 14 | remove(index) { 15 | this.items.splice(index, 1); 16 | 17 | this.$emit('removed'); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | actingAs($user ?? create(User::class)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /database/factories/FavoriteFactory.php: -------------------------------------------------------------------------------- 1 | define(Favorite::class, function (Faker $faker) { 11 | return [ 12 | 'user_id' => factory(User::class), 13 | 'favorable_id' => $reply = factory(Reply::class)->create(), 14 | 'favorable_type' => get_class($reply), 15 | ]; 16 | }); 17 | -------------------------------------------------------------------------------- /database/factories/ReplyFactory.php: -------------------------------------------------------------------------------- 1 | define(Reply::class, function (Faker $faker) { 11 | return [ 12 | 'user_id' => User::count() ? User::pluck('id')->random() : factory(User::class), 13 | 'thread_id' => factory(Thread::class), 14 | 'body' => $faker->paragraph, 15 | ]; 16 | }); 17 | -------------------------------------------------------------------------------- /tests/CreatesApplication.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 19 | 20 | return $app; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Homestead.yaml: -------------------------------------------------------------------------------- 1 | ip: 192.168.10.10 2 | memory: 2048 3 | cpus: 2 4 | provider: virtualbox 5 | authorize: ~/.ssh/id_rsa.pub 6 | keys: 7 | - ~/.ssh/id_rsa 8 | folders: 9 | - 10 | map: . 11 | to: /home/vagrant/forum 12 | sites: 13 | - 14 | map: forum.test 15 | to: /home/vagrant/forum/public 16 | php: "7.4" 17 | databases: 18 | - forum 19 | features: 20 | - 21 | mariadb: false 22 | - 23 | ohmyzsh: false 24 | - 25 | webdriver: false 26 | name: forum 27 | hostname: forum 28 | -------------------------------------------------------------------------------- /app/Providers/BroadcastServiceProvider.php: -------------------------------------------------------------------------------- 1 | create()->each(function ($thread) { 17 | factory(Reply::class, rand(1, 5))->create(['thread_id' => $thread]); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ $user->name }} favorited 4 | {{ $activity->subject->favorable->owner->name }} reply on 5 | {{ $activity->subject->favorable->thread->title }} 6 | 7 | 8 | {{ $activity->subject->favorable->body }} 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/UserController.php: -------------------------------------------------------------------------------- 1 | take(5) 22 | ->pluck('name'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/UserAvatarController.php: -------------------------------------------------------------------------------- 1 | validate([ 13 | 'avatar' => ['required', 'image'] 14 | ]); 15 | 16 | auth()->user()->update([ 17 | 'avatar_path' => request()->file('avatar')->store('avatars', 'public') 18 | ]); 19 | 20 | return response([], 204); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 18 | return route('login'); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Unit/ChannelTest.php: -------------------------------------------------------------------------------- 1 | $channel->id]); 18 | 19 | $this->assertInstanceOf(Collection::class, $channel->threads); 20 | $this->assertTrue($channel->threads->contains($thread)); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix'); 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Mix Asset Management 6 | |-------------------------------------------------------------------------- 7 | | 8 | | Mix provides a clean, fluent API for defining some Webpack build steps 9 | | for your Laravel application. By default, we are compiling the Sass 10 | | file for the application as well as bundling up all the JS files. 11 | | 12 | */ 13 | 14 | mix.js('resources/js/app.js', 'public/js') 15 | .sass('resources/sass/app.scss', 'public/css'); 16 | -------------------------------------------------------------------------------- /database/factories/DatabaseNotificationFactory.php: -------------------------------------------------------------------------------- 1 | define(DatabaseNotification::class, function (Faker $faker) { 9 | return [ 10 | 'id' => \Ramsey\Uuid\Uuid::uuid4()->toString(), 11 | 'type' => 'App\Notifications\ThreadWasUpdated', 12 | 'notifiable_id' => auth()->id() ?: factory('App\User'), 13 | 'notifiable_type' => 'App\User', 14 | 'data' => ['foo' => 'bar'], 15 | ]; 16 | }); 17 | -------------------------------------------------------------------------------- /app/Http/Middleware/Administrator.php: -------------------------------------------------------------------------------- 1 | check() && auth()->user()->isAdmin()) { 19 | return $next($request); 20 | } 21 | 22 | abort(403, 'You do not have permission to perform this action.'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 17 | } 18 | 19 | /** 20 | * Show the application dashboard. 21 | * 22 | * @return \Illuminate\Contracts\Support\Renderable 23 | */ 24 | public function index() 25 | { 26 | return view('home'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /database/factories/ThreadFactory.php: -------------------------------------------------------------------------------- 1 | define(Thread::class, function (Faker $faker) { 11 | $title = $faker->sentence; 12 | 13 | return [ 14 | 'user_id' => User::count() ? User::pluck('id')->random() : factory(User::class), 15 | 'channel_id' => factory(Channel::class), 16 | 'title' => $title, 17 | 'body' => $faker->paragraph, 18 | 'visits' => 0, 19 | 'locked' => false 20 | ]; 21 | }); 22 | -------------------------------------------------------------------------------- /resources/lang/en/pagination.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /routes/channels.php: -------------------------------------------------------------------------------- 1 | id === (int) $id; 18 | }); 19 | -------------------------------------------------------------------------------- /app/Inspections/Spam.php: -------------------------------------------------------------------------------- 1 | inspections as $inspection) { 26 | app($inspection)->detect($body); 27 | } 28 | 29 | return false; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | get('/user', function (Request $request) { 18 | return $request->user(); 19 | }); 20 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | $uri = urldecode( 11 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 12 | ); 13 | 14 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 15 | // built-in PHP web server. This provides a convenient way to test a Laravel 16 | // application without having installed a "real" web server software here. 17 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 18 | return false; 19 | } 20 | 21 | require_once __DIR__.'/public/index.php'; 22 | -------------------------------------------------------------------------------- /app/Http/Controllers/SearchController.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 20 | return Thread::search(request('q'))->paginate(25); 21 | } 22 | 23 | return view('threads.search', [ 24 | 'trending' => $trending->get() 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 19 | })->describe('Display an inspiring quote'); 20 | -------------------------------------------------------------------------------- /resources/js/components/ImageUpload.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /resources/js/components/SubscribeButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 26 | -------------------------------------------------------------------------------- /resources/lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /app/Inspections/InvalidKeywords.php: -------------------------------------------------------------------------------- 1 | keywords as $keyword) { 27 | if (stripos($body, $keyword) !== false) { 28 | throw new Exception('Your reply contains spam.'); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Listeners/NotifyThreadSubscribers.php: -------------------------------------------------------------------------------- 1 | thread->notifySubscribers($event->reply); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 22 | return redirect(RouteServiceProvider::HOME); 23 | } 24 | 25 | return $next($request); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/views/home.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |
8 |
Dashboard
9 | 10 |
11 | @if (session('status')) 12 | 15 | @endif 16 | 17 | You are logged in! 18 |
19 |
20 |
21 |
22 |
23 | @endsection 24 | -------------------------------------------------------------------------------- /tests/Unit/SpamTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($spam->detect('Innocent reply here')); 16 | 17 | $this->expectException('Exception'); 18 | 19 | $spam->detect('yahoo customer support'); 20 | } 21 | 22 | /** @test */ 23 | public function it_checks_for_any_key_being_held_down() 24 | { 25 | $spam = new Spam(); 26 | 27 | $this->expectException('Exception'); 28 | 29 | $spam->detect('Hello world aaaaaaaaa'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/ForgotPasswordController.php: -------------------------------------------------------------------------------- 1 | reply->notifyMentionedUsers(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /resources/lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Your password has been reset!', 17 | 'sent' => 'We have emailed your password reset link!', 18 | 'throttled' => 'Please wait before retrying.', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that email address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /database/migrations/2020_03_31_120000_create_channels_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name', 50); 19 | $table->string('slug', 50); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('channels'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | with('channels', $channels); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /resources/js/components/WYSIWYG.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_100000_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 18 | $table->string('token'); 19 | $table->timestamp('created_at')->nullable(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('password_resets'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Feature/ProfileTest.php: -------------------------------------------------------------------------------- 1 | get('profiles/' . $user->name) 20 | ->assertSee($user->name); 21 | } 22 | 23 | 24 | /** @test */ 25 | public function profile_display_user_threads() 26 | { 27 | $this->signIn(); 28 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 29 | 30 | $this->get('profiles/' . auth()->user()->name) 31 | ->assertSee($thread->title) 32 | ->assertSee($thread->body); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tests/Feature/SearchTest.php: -------------------------------------------------------------------------------- 1 | 'algolia']); 16 | 17 | create(Thread::class, [], 2); 18 | create(Thread::class, ['body' => 'A thread with the foobar term.'], 2); 19 | 20 | do { 21 | // Account for latency. 22 | sleep(.25); 23 | 24 | $results = $this->getJson('/threads/search?q=foobar')->json()['data']; 25 | } while (empty($results)); 26 | 27 | $this->assertCount(2, $results); 28 | 29 | // Clean up. 30 | Thread::latest()->take(4)->unsearchable(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Unit/UserTest.php: -------------------------------------------------------------------------------- 1 | $user->id]); 18 | 19 | $this->assertEquals($reply->id, $user->lastReply->id); 20 | } 21 | 22 | /** @test */ 23 | public function a_user_can_determine_their_avatar_path() 24 | { 25 | $user = create(User::class); 26 | 27 | $this->assertEquals(asset(Storage::url('avatars/default.png')), $user->avatar_path); 28 | 29 | $user->avatar_path = 'avatars/me.jpg'; 30 | 31 | $this->assertEquals(asset(Storage::url('avatars/me.jpg')), $user->avatar_path); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /after.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # If you would like to do some extra provisioning you may 4 | # add any commands you wish to this file and they will 5 | # be run after the Homestead machine is provisioned. 6 | # 7 | # If you have user-specific configurations you would like 8 | # to apply, you may also create user-customizations.sh, 9 | # which will be run after this script. 10 | 11 | # If you're not quite ready for Node 12.x 12 | # Uncomment these lines to roll back to 13 | # v11.x or v10.x 14 | 15 | # Remove Node.js v12.x: 16 | #sudo apt-get -y purge nodejs 17 | #sudo rm -rf /usr/lib/node_modules/npm/lib 18 | #sudo rm -rf //etc/apt/sources.list.d/nodesource.list 19 | 20 | # Install Node.js v11.x 21 | #curl -sL https://deb.nodesource.com/setup_11.x | sudo -E bash - 22 | #sudo apt-get install -y nodejs 23 | 24 | # Install Node.js v10.x 25 | #curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - 26 | #sudo apt-get install -y nodejs 27 | -------------------------------------------------------------------------------- /config/cors.php: -------------------------------------------------------------------------------- 1 | ['api/*'], 19 | 20 | 'allowed_methods' => ['*'], 21 | 22 | 'allowed_origins' => ['*'], 23 | 24 | 'allowed_origins_patterns' => [], 25 | 26 | 'allowed_headers' => ['*'], 27 | 28 | 'exposed_headers' => [], 29 | 30 | 'max_age' => 0, 31 | 32 | 'supports_credentials' => false, 33 | 34 | ]; 35 | -------------------------------------------------------------------------------- /tests/Feature/SubscribeToThreadsTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 16 | $thread = create(Thread::class); 17 | 18 | $this->post($thread->path() . '/subscriptions'); 19 | 20 | $this->assertCount(1, $thread->fresh()->subscriptions); 21 | } 22 | 23 | /** @test */ 24 | public function a_user_can_unsubscribe_from_threads() 25 | { 26 | $this->signIn(); 27 | 28 | $thread = create(Thread::class); 29 | 30 | $thread->subscribe(); 31 | 32 | $this->delete($thread->path() . '/subscriptions'); 33 | 34 | $this->assertCount(0, $thread->subscriptions); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2019_08_19_000000_create_failed_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->text('connection'); 19 | $table->text('queue'); 20 | $table->longText('payload'); 21 | $table->longText('exception'); 22 | $table->timestamp('failed_at')->useCurrent(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('failed_jobs'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Feature/TrendingThreadsTest.php: -------------------------------------------------------------------------------- 1 | trending = new Trending(); 19 | $this->trending->reset(); 20 | } 21 | 22 | /** @test */ 23 | public function it_increments_a_threads_score_each_time_it_is_read() 24 | { 25 | $this->assertEmpty($this->trending->get()); 26 | 27 | $thread = create(Thread::class); 28 | 29 | $this->call('GET', $thread->path()); 30 | 31 | $this->assertCount(1, $trending = $this->trending->get()); 32 | 33 | $this->assertEquals($thread->title, $trending[0]->title); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /app/Filters/ThreadFilters.php: -------------------------------------------------------------------------------- 1 | firstOrFail(); 18 | 19 | return $this->builder->whereUserId($user->id); 20 | } 21 | 22 | protected function popularity($value) 23 | { 24 | // $this->builder->getQuery()->orders = []; 25 | return $this->builder->orderBy('replies_count', 'desc'); 26 | } 27 | 28 | /** 29 | * Filter the query according to those that are unanswered. 30 | * 31 | * @return \Illuminate\Database\Eloquent\Builder 32 | */ 33 | protected function unanswered() 34 | { 35 | return $this->builder->where('replies_count', 0); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /database/migrations/2020_04_05_174520_create_notifications_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 18 | $table->string('type'); 19 | $table->morphs('notifiable'); 20 | $table->text('data'); 21 | $table->timestamp('read_at')->nullable(); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('notifications'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /resources/views/profiles/show.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |
8 |
9 | Since {{ $user->created_at }} 10 | 11 |
12 |
13 | 14 | @forelse ($activities as $date => $activity) 15 |

{{ $date }}

16 | @foreach($activity as $record) 17 | @includeIf("profiles.activities.{$record->type}", ['activity' => $record]) 18 | @endforeach 19 | @empty 20 |

There is no activity for this user yet.

21 | @endforelse 22 |
23 |
24 |
25 | @endsection 26 | -------------------------------------------------------------------------------- /app/Filters/Filters.php: -------------------------------------------------------------------------------- 1 | request = $request; 20 | } 21 | 22 | /** 23 | * @param $builder 24 | * @return Request 25 | */ 26 | public function apply($builder) 27 | { 28 | $this->builder = $builder; 29 | 30 | foreach ($this->filters() as $filter => $value) { 31 | if (method_exists($this, $filter)) { 32 | $this->$filter($value); 33 | } 34 | } 35 | 36 | return $this->builder; 37 | } 38 | 39 | private function filters() 40 | { 41 | return $this->request->only($this->filters); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/ResetPasswordController.php: -------------------------------------------------------------------------------- 1 | 'App\Policies\ThreadPolicy', 17 | 'App\Reply' => 'App\Policies\ReplyPolicy', 18 | 'App\User' => 'App\Policies\UserPolicy', 19 | // 'App\Model' => 'App\Policies\ModelPolicy', 20 | ]; 21 | 22 | /** 23 | * Register any authentication / authorization services. 24 | * 25 | * @return void 26 | */ 27 | public function boot() 28 | { 29 | $this->registerPolicies(); 30 | 31 | // Gate::before(function ($user) { 32 | // if ($user->name == 'Artūras') return true; 33 | // }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Rules/SpamFree.php: -------------------------------------------------------------------------------- 1 | detect($value); 31 | } catch (\Exception $e) { 32 | return false; 33 | } 34 | } 35 | 36 | /** 37 | * Get the validation error message. 38 | * 39 | * @return string 40 | */ 41 | public function message() 42 | { 43 | return 'The :attribute contains spam.'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | command('inspire')->hourly(); 28 | } 29 | 30 | /** 31 | * Register the commands for the application. 32 | * 33 | * @return void 34 | */ 35 | protected function commands() 36 | { 37 | $this->load(__DIR__.'/Commands'); 38 | 39 | require base_path('routes/console.php'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /database/migrations/2020_04_02_123700_create_activities_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->unsignedBigInteger('user_id')->index(); 19 | $table->string('type', 50); 20 | $table->morphs('subject'); 21 | $table->timestamps(); 22 | 23 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('activities'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2020_04_01_181057_create_favorites_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->unsignedBigInteger('user_id'); 19 | $table->morphs('favorable'); 20 | $table->timestamps(); 21 | 22 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 23 | $table->unique(['user_id', 'favorable_id', 'favorable_type']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('favorites'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2020_04_14_160518_add_foreign_key_to_threads_table.php: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('best_reply_id')->nullable()->after('channel_id'); 18 | $table->foreign('best_reply_id')->references('id')->on('replies')->onDelete('set null'); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::table('threads', function (Blueprint $table) { 30 | $table->dropForeign(['best_reply_id']); 31 | $table->dropColumn('best_reply_id'); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name')->unique(); 19 | $table->string('email')->unique(); 20 | $table->timestamp('email_verified_at')->nullable(); 21 | $table->string('password'); 22 | $table->string('avatar_path')->nullable(); 23 | $table->rememberToken(); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('users'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker $faker) { 21 | return [ 22 | 'name' => $faker->name, 23 | 'email' => $faker->unique()->safeEmail, 24 | 'email_verified_at' => now(), 25 | 'password' => bcrypt('labas'), 26 | 'remember_token' => Str::random(10), 27 | ]; 28 | }); 29 | 30 | $factory->state(User::class, 'administrator', function () { 31 | return [ 32 | 'name' => 'JohnDoe' 33 | ]; 34 | }); 35 | -------------------------------------------------------------------------------- /database/migrations/2020_03_31_125700_create_replies_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->unsignedBigInteger('user_id'); 19 | $table->unsignedBigInteger('thread_id'); 20 | $table->text('body'); 21 | $table->timestamps(); 22 | 23 | $table->foreign('thread_id')->references('id')->on('threads')->onDelete('cascade'); 24 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('replies'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Forum 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://forum.test 6 | 7 | LOG_CHANNEL=stack 8 | 9 | DB_CONNECTION=mysql 10 | DB_HOST=127.0.0.1 11 | DB_PORT=3306 12 | DB_DATABASE=forum 13 | DB_USERNAME=homestead 14 | DB_PASSWORD=secret 15 | 16 | BROADCAST_DRIVER=log 17 | CACHE_DRIVER=file 18 | QUEUE_CONNECTION=sync 19 | SESSION_DRIVER=file 20 | SESSION_LIFETIME=120 21 | 22 | REDIS_HOST=127.0.0.1 23 | REDIS_PASSWORD=null 24 | REDIS_PORT=6379 25 | REDIS_PREFIX="" 26 | 27 | MAIL_MAILER=smtp 28 | MAIL_HOST=smtp.mailtrap.io 29 | MAIL_PORT=2525 30 | MAIL_USERNAME=null 31 | MAIL_PASSWORD=null 32 | MAIL_ENCRYPTION=null 33 | MAIL_FROM_ADDRESS=null 34 | MAIL_FROM_NAME="${APP_NAME}" 35 | 36 | AWS_ACCESS_KEY_ID= 37 | AWS_SECRET_ACCESS_KEY= 38 | AWS_DEFAULT_REGION=us-east-1 39 | AWS_BUCKET= 40 | 41 | PUSHER_APP_ID= 42 | PUSHER_APP_KEY= 43 | PUSHER_APP_SECRET= 44 | PUSHER_APP_CLUSTER=mt1 45 | 46 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" 47 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 48 | 49 | RECAPTCHA_SECRET= 50 | SCOUT_DRIVER=algolia 51 | ALGOLIA_APP_ID= 52 | ALGOLIA_KEY= 53 | ALGOLIA_SECRET= 54 | -------------------------------------------------------------------------------- /app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 21 | SendEmailVerificationNotification::class, 22 | ], 23 | ThreadReceivedNewReply::class => [ 24 | NotifyMentionedUsers::class, 25 | NotifyThreadSubscribers::class, 26 | ], 27 | ]; 28 | 29 | /** 30 | * Register any events for your application. 31 | * 32 | * @return void 33 | */ 34 | public function boot() 35 | { 36 | parent::boot(); 37 | // 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/Events/ThreadReceivedNewReply.php: -------------------------------------------------------------------------------- 1 | thread = $thread; 28 | $this->reply = $reply; 29 | } 30 | 31 | /** 32 | * Get the channels the event should broadcast on. 33 | * 34 | * @return \Illuminate\Broadcasting\Channel|array 35 | */ 36 | public function broadcastOn() 37 | { 38 | return new PrivateChannel('channel-name'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Rules/Recaptcha.php: -------------------------------------------------------------------------------- 1 | post('https://www.google.com/recaptcha/api/siteverify', [ 30 | 'secret' => config('services.recaptcha.secret'), 31 | 'response' => $value, 32 | 'remoteip' => request()->ip() 33 | ])->json()['success']; 34 | } 35 | 36 | /** 37 | * Get the validation error message. 38 | * 39 | * @return string 40 | */ 41 | public function message() 42 | { 43 | return 'The recaptcha verification failed. Try again.'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/Visits.php: -------------------------------------------------------------------------------- 1 | thread = $thread; 27 | } 28 | 29 | public function record() { 30 | Redis::incr($this->getChacheKey()); 31 | } 32 | 33 | /** 34 | * @return int 35 | */ 36 | public function count() 37 | { 38 | return Redis::get($this->getChacheKey()) ?? 0; 39 | } 40 | 41 | /** 42 | * @return string 43 | */ 44 | protected function getChacheKey() 45 | { 46 | return "threads.{$this->thread->id}.visits"; 47 | } 48 | 49 | public function reset() 50 | { 51 | Redis::del($this->getChacheKey()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'domain' => env('MAILGUN_DOMAIN'), 19 | 'secret' => env('MAILGUN_SECRET'), 20 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 21 | ], 22 | 23 | 'postmark' => [ 24 | 'token' => env('POSTMARK_TOKEN'), 25 | ], 26 | 27 | 'ses' => [ 28 | 'key' => env('AWS_ACCESS_KEY_ID'), 29 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 30 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 31 | ], 32 | 33 | 'recaptcha' => [ 34 | 'secret' => env('RECAPTCHA_SECRET') 35 | ] 36 | ]; 37 | -------------------------------------------------------------------------------- /database/migrations/2020_04_05_163901_create_thread_subscriptions_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->unsignedBigInteger('user_id'); 19 | $table->unsignedBigInteger('thread_id'); 20 | $table->timestamps(); 21 | 22 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 23 | $table->foreign('thread_id')->references('id')->on('threads')->onDelete('cascade'); 24 | $table->unique(['user_id', 'thread_id']); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('thread_subscriptions'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/LoginController.php: -------------------------------------------------------------------------------- 1 | middleware('guest')->except('logout'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Http/Requests/CreatePostRequest.php: -------------------------------------------------------------------------------- 1 | ['required', new SpamFree] 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /config/view.php: -------------------------------------------------------------------------------- 1 | [ 17 | resource_path('views'), 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled View Path 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This option determines where all the compiled Blade templates will be 26 | | stored for your application. Typically, this is within the storage 27 | | directory. However, as usual, you are free to change this value. 28 | | 29 | */ 30 | 31 | 'compiled' => env( 32 | 'VIEW_COMPILED_PATH', 33 | realpath(storage_path('framework/views')) 34 | ), 35 | 36 | ]; 37 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/ConfirmPasswordController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Trending.php: -------------------------------------------------------------------------------- 1 | cacheKey(), 0, 4)); 17 | } 18 | 19 | /** 20 | * Get the cache key name. 21 | * 22 | * @return string 23 | */ 24 | public function cacheKey() 25 | { 26 | return app()->environment('testing') 27 | ? 'testing_trending_threads' 28 | : 'trending_threads'; 29 | } 30 | 31 | /** 32 | * Push a new thread to the trending list. 33 | * 34 | * @param Thread $thread 35 | */ 36 | public function push($thread) 37 | { 38 | Redis::zincrby( 39 | $this->cacheKey(), 40 | 1, 41 | json_encode([ 42 | 'title' => $thread->title, 43 | 'path' => $thread->path() 44 | ]) 45 | ); 46 | } 47 | 48 | /** 49 | * Reset all trending threads. 50 | */ 51 | public function reset() 52 | { 53 | Redis::del($this->cacheKey()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /resources/views/auth/verify.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |
8 |
{{ __('Verify Your Email Address') }}
9 | 10 |
11 | @if (session('resent')) 12 | 15 | @endif 16 | 17 | {{ __('Before proceeding, please check your email for a verification link.') }} 18 | {{ __('If you did not receive the email') }}, 19 |
20 | @csrf 21 | . 22 |
23 |
24 |
25 |
26 |
27 |
28 | @endsection 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | ./tests/Unit 9 | 10 | 11 | ./tests/Feature 12 | 13 | 14 | 15 | 16 | ./app 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/Feature/MentionUserTest.php: -------------------------------------------------------------------------------- 1 | withoutExceptionHandling(); 16 | $john = create(User::class, ['name' => 'JohnDoe']); 17 | 18 | $this->signIn($john); 19 | 20 | $jane = create(User::class, ['name' => 'JaneDoe']); 21 | 22 | $thread = create(Thread::class); 23 | 24 | $reply = make(Reply::class, [ 25 | 'body' => 'Hey @JaneDoe check this out.' 26 | ]); 27 | 28 | $this->json('post', $thread->path() . '/replies', $reply->toArray()); 29 | 30 | $this->assertCount(1, $jane->notifications); 31 | } 32 | 33 | /** @test */ 34 | public function it_can_fetch_all_mentioned_users_starting_with_the_given_characters() 35 | { 36 | create(User::class, ['name' => 'johndoe']); 37 | create(User::class, ['name' => 'johndoe2']); 38 | create(User::class, ['name' => 'janedoe']); 39 | 40 | $results = $this->json('GET', '/api/users', ['name' => 'john']); 41 | 42 | $this->assertCount(2, $results->json()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) 2 | [![Chat](https://img.shields.io/discord/620935790867906561?label=chat)](https://discord.gg/YeJBQrTUT9) 3 | ![HitCount](https://views.whatilearened.today/views/github/keizah7/forum.svg) 4 | ![Forks](https://img.shields.io/github/forks/keizah7/forum?style=social) 5 | ![Stars](https://img.shields.io/github/stars/keizah7/forum?style=social) 6 | ![Watchers](https://img.shields.io/github/watchers/keizah7/forum?style=social) 7 | ![Contributors](https://img.shields.io/github/contributors/keizah7/forum) 8 | 9 | # Let's Build A Forum with Laravel and TDD 10 | 11 | ### Forum: 12 | This project is for educational porpuses only. Pull request are welcome! Thank you for your cooperation! 13 | 14 | ## Installation 15 | `.env` 16 | ``` 17 | RECAPTCHA_SECRET=(v2 checkbox) 18 | ALGOLIA_APP_ID= 19 | ALGOLIA_KEY= 20 | ALGOLIA_SECRET= 21 | ``` 22 | Commands 23 | ``` 24 | git clone https://github.com/keizah7/forum.git forum 25 | cd forum 26 | composer install 27 | vagrant up 28 | vagrant ssh 29 | php74 30 | cd forum 31 | npm install && npm run dev 32 | php artisan migrate:fresh --seed 33 | php artisan scout:import 'App\Thread' 34 | ``` 35 | 36 | ### Authors 37 | [Artūras](https://github.com/keizah7) ![Followers](https://img.shields.io/github/followers/keizah7?style=social) 38 | -------------------------------------------------------------------------------- /app/RecordsActivity.php: -------------------------------------------------------------------------------- 1 | guest()) return; 10 | 11 | foreach (static::recordableEvents() as $event) { 12 | static::created(function ($model) use ($event) { 13 | $model->recordActivity($event); 14 | }); 15 | } 16 | 17 | static::deleting(function ($model) { 18 | $model->activity()->delete(); 19 | }); 20 | } 21 | 22 | protected static function recordableEvents() 23 | { 24 | return ['created']; 25 | } 26 | 27 | public function recordActivity($event) 28 | { 29 | $this->activity()->create([ 30 | 'type' => $this->getActivityType($event), 31 | 'user_id' => auth()->id(), 32 | 'subject_id' => $this->id, 33 | 'subject_type' => get_class($this), 34 | ]); 35 | } 36 | 37 | public function activity() 38 | { 39 | return $this->morphMany(Activity::class, 'subject'); 40 | } 41 | 42 | /** 43 | * @param $event 44 | * @return string 45 | */ 46 | protected function getActivityType($event): string 47 | { 48 | return $event . '_' . strtolower(class_basename($this)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/VerificationController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 39 | $this->middleware('signed')->only('verify'); 40 | $this->middleware('throttle:6,1')->only('verify', 'resend'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /resources/js/components/Flash.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 47 | 48 | 55 | -------------------------------------------------------------------------------- /resources/js/components/UserNotifications.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 41 | -------------------------------------------------------------------------------- /resources/js/components/Favorite.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 53 | -------------------------------------------------------------------------------- /database/migrations/2020_03_31_125699_create_threads_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('slug')->unique()->nullable(); 19 | $table->unsignedBigInteger('user_id'); 20 | $table->unsignedBigInteger('channel_id'); 21 | $table->unsignedInteger('replies_count')->default(0); 22 | $table->unsignedInteger('visits')->default(0); 23 | $table->string('title'); 24 | $table->text('body'); 25 | $table->boolean('locked')->default(false); 26 | $table->timestamps(); 27 | 28 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 29 | $table->foreign('channel_id')->references('id')->on('channels')->onDelete('cascade'); 30 | }); 31 | } 32 | 33 | /** 34 | * Reverse the migrations. 35 | * 36 | * @return void 37 | */ 38 | public function down() 39 | { 40 | Schema::dropIfExists('threads'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /resources/js/components/AvatarForm.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 46 | -------------------------------------------------------------------------------- /resources/js/bootstrap.js: -------------------------------------------------------------------------------- 1 | window._ = require('lodash'); 2 | 3 | /** 4 | * We'll load jQuery and the Bootstrap jQuery plugin which provides support 5 | * for JavaScript based Bootstrap features such as modals and tabs. This 6 | * code may be modified to fit the specific needs of your application. 7 | */ 8 | 9 | try { 10 | window.Popper = require('popper.js').default; 11 | window.$ = window.jQuery = require('jquery'); 12 | 13 | require('bootstrap'); 14 | } catch (e) {} 15 | 16 | /** 17 | * We'll load the axios HTTP library which allows us to easily issue requests 18 | * to our Laravel back-end. This library automatically handles sending the 19 | * CSRF token as a header based on the value of the "XSRF" token cookie. 20 | */ 21 | 22 | window.axios = require('axios'); 23 | 24 | window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 25 | 26 | /** 27 | * Echo exposes an expressive API for subscribing to channels and listening 28 | * for events that are broadcast by Laravel. Echo and event broadcasting 29 | * allows your team to easily build robust real-time web applications. 30 | */ 31 | 32 | // import Echo from 'laravel-echo'; 33 | 34 | // window.Pusher = require('pusher-js'); 35 | 36 | // window.Echo = new Echo({ 37 | // broadcaster: 'pusher', 38 | // key: process.env.MIX_PUSHER_APP_KEY, 39 | // cluster: process.env.MIX_PUSHER_APP_CLUSTER, 40 | // encrypted: true 41 | // }); 42 | -------------------------------------------------------------------------------- /app/Channel.php: -------------------------------------------------------------------------------- 1 | hasMany(Thread::class); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Feature/AddAvatarTest.php: -------------------------------------------------------------------------------- 1 | json('POST', 'api/users/'.$user->id.'/avatars') 19 | ->assertRedirect('login'); 20 | } 21 | 22 | /** @test */ 23 | public function a_valid_avatar_must_be_provided() 24 | { 25 | $this->signIn(); 26 | 27 | $this->json('POST', 'api/users/' . auth()->id() . '/avatars', [ 28 | 'avatar' => 'not-an-image' 29 | ])->assertStatus(422); 30 | } 31 | 32 | /** @test */ 33 | public function a_user_may_add_an_avatar_to_their_profile() 34 | { 35 | $this->signIn(); 36 | 37 | Storage::fake('public'); 38 | 39 | $this->json('POST', 'api/users/' . auth()->id() . '/avatars', [ 40 | 'avatar' => $file = UploadedFile::fake()->image('avatars.jpg') 41 | ]); 42 | 43 | $this->assertEquals(asset(Storage::url('avatars/'.$file->hashName())), auth()->user()->avatar_path); 44 | 45 | Storage::disk('public')->assertExists('avatars/' . $file->hashName()); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /app/Favorable.php: -------------------------------------------------------------------------------- 1 | favorites->each->delete(); 14 | }); 15 | } 16 | 17 | public function isFavorited() 18 | { 19 | return !! $this->favorites->where('user_id', auth()->id())->count(); 20 | } 21 | 22 | public function getFavoritesCountAttribute() 23 | { 24 | return $this->favorites->count(); 25 | } 26 | 27 | public function favorites() 28 | { 29 | return $this->morphMany(Favorite::class, 'favorable'); 30 | } 31 | 32 | public function favorite() 33 | { 34 | $attributes = ['user_id' => auth()->id()]; 35 | if (!$this->favorites()->where($attributes)->exists()) { 36 | return $this->favorites()->create($attributes); 37 | } 38 | } 39 | 40 | /** 41 | * Unfavorite the current reply. 42 | */ 43 | public function unfavorite() 44 | { 45 | $attributes = ['user_id' => auth()->id()]; 46 | 47 | $this->favorites()->where($attributes)->get()->each->delete(); 48 | } 49 | 50 | /** 51 | * Fetch the favorited status as a property. 52 | * 53 | * @return bool 54 | */ 55 | public function getIsFavoritedAttribute() 56 | { 57 | return $this->isFavorited(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Feature/FavoriteTest.php: -------------------------------------------------------------------------------- 1 | post('replies/' . create(Reply::class)->id . '/favorites')->assertRedirect('login'); 17 | } 18 | 19 | /** @test */ 20 | public function an_authenticated_user_can_favorite_any_reply() 21 | { 22 | $reply = create(Reply::class); 23 | 24 | $this->signIn()->post('replies/' . $reply->id . '/favorites'); 25 | 26 | $this->assertCount(1, $reply->favorites); 27 | } 28 | 29 | 30 | /** @test */ 31 | public function an_authenticated_user_can_favorite_reply_only_once() 32 | { 33 | $this->signIn(); 34 | $reply = create(Reply::class); 35 | 36 | $reply->favorite(); 37 | $reply->favorite(); 38 | 39 | $this->assertCount(1, $reply->favorites); 40 | } 41 | 42 | /** @test */ 43 | public function an_authenticated_user_can_unfavorite_a_reply() 44 | { 45 | $this->signIn(); 46 | 47 | $reply = create(Reply::class); 48 | 49 | $reply->favorite(); 50 | 51 | $this->delete('replies/' . $reply->id . '/favorites'); 52 | 53 | $this->assertCount(0, $reply->favorites); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Feature/UpdateThreadTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 16 | } 17 | 18 | /** @test */ 19 | public function unauthorized_users_may_not_update_threads() 20 | { 21 | $thread = create(Thread::class, ['user_id' => create(User::class)->id]); 22 | 23 | $this->patch($thread->path(), [])->assertStatus(403); 24 | } 25 | 26 | /** @test */ 27 | public function a_thread_requires_a_title_and_body_to_be_updated() 28 | { 29 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 30 | 31 | $this->patch($thread->path(), ['title' => 'Changed'])->assertSessionHasErrors('body'); 32 | 33 | $this->patch($thread->path(), ['body' => 'Changed'])->assertSessionHasErrors('title'); 34 | } 35 | 36 | /** @test */ 37 | public function a_thread_can_be_updated_by_its_creator() 38 | { 39 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 40 | 41 | $this->patch($thread->path(), ['title' => 'Changed', 'body' => 'Changed body.']); 42 | 43 | tap($thread->fresh(), function ($thread) { 44 | $this->assertEquals('Changed', $thread->title); 45 | $this->assertEquals('Changed body.', $thread->body); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "npm run development -- --watch", 7 | "watch-poll": "npm run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "npm run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 11 | }, 12 | "devDependencies": { 13 | "axios": "^0.19", 14 | "bootstrap": "^4.0.0", 15 | "cross-env": "^7.0", 16 | "jquery": "^3.5.0", 17 | "laravel-mix": "^5.0.1", 18 | "lodash": "^4.17.13", 19 | "popper.js": "^1.12", 20 | "resolve-url-loader": "^2.3.1", 21 | "sass": "^1.20.1", 22 | "sass-loader": "^8.0.0", 23 | "vue": "^2.5.17", 24 | "vue-template-compiler": "^2.6.10" 25 | }, 26 | "dependencies": { 27 | "at.js": "^1.5.4", 28 | "font-awesome": "^4.7.0", 29 | "jquery.caret": "^0.3.1", 30 | "moment": "^2.24.0", 31 | "trix": "^1.2.3", 32 | "vue-instantsearch": "^1.3.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /resources/js/components/Paginator.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 51 | -------------------------------------------------------------------------------- /resources/views/threads/show.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @push('head') 4 | 5 | @endpush 6 | 7 | @section('content') 8 | 9 |
10 |
11 |
12 | @include ('threads._question') 13 | 14 | 15 |
16 |
17 |
18 |
19 | Published {{ $thread->created_at->diffForHumans() }} and has 20 | {{ Str::plural('reply', $thread->replies_count) }} 21 | 22 |

23 | 24 | 25 | 29 |

30 |
31 |
32 |
33 |
34 |
35 |
36 | @endsection 37 | -------------------------------------------------------------------------------- /resources/views/threads/_list.blade.php: -------------------------------------------------------------------------------- 1 | @forelse($threads as $thread) 2 |
3 |
4 | 27 |
28 | 29 |
30 |
{!! $thread->body !!}
31 |
32 | 33 | 36 |
37 | @empty 38 | List is empty 39 | @endforelse 40 | -------------------------------------------------------------------------------- /resources/js/components/Replies.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 55 | -------------------------------------------------------------------------------- /resources/js/pages/Thread.vue: -------------------------------------------------------------------------------- 1 | 47 | -------------------------------------------------------------------------------- /app/Favorite.php: -------------------------------------------------------------------------------- 1 | morphTo(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Feature/TriggerActivityTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 20 | 21 | $thread = create(Thread::class); 22 | 23 | $this->assertDatabaseHas('activities', [ 24 | 'type' => 'created_thread', 25 | 'user_id' => auth()->id(), 26 | 'subject_id' => $thread->id, 27 | 'subject_type' => get_class($thread), 28 | ]); 29 | 30 | $activity = Activity::first(); 31 | 32 | $this->assertEquals($activity->subject->id, $thread->id); 33 | } 34 | 35 | /** @test */ 36 | public function creating_a_reply() 37 | { 38 | $this->signIn(); 39 | 40 | create(Reply::class); 41 | 42 | $this->assertEquals(2, Activity::count()); 43 | } 44 | 45 | /** @test */ 46 | public function it_fetches_a_feed_for_any_user() 47 | { 48 | $this->signIn(); 49 | create(Thread::class, ['user_id' => auth()->id()], 2); 50 | auth()->user()->activity()->first()->update(['created_at' => Carbon::now()->subWeek()]); 51 | 52 | $feed = Activity::feed(auth()->user()); 53 | $this->assertTrue($feed->keys()->contains(Carbon::now()->format('Y-m-d'))); 54 | $this->assertTrue($feed->keys()->contains(Carbon::now()->subWeek()->format('Y-m-d'))); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /config/hashing.php: -------------------------------------------------------------------------------- 1 | 'bcrypt', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Bcrypt Options 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may specify the configuration options that should be used when 26 | | passwords are hashed using the Bcrypt algorithm. This will allow you 27 | | to control the amount of time it takes to hash the given password. 28 | | 29 | */ 30 | 31 | 'bcrypt' => [ 32 | 'rounds' => env('BCRYPT_ROUNDS', 10), 33 | ], 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Argon Options 38 | |-------------------------------------------------------------------------- 39 | | 40 | | Here you may specify the configuration options that should be used when 41 | | passwords are hashed using the Argon algorithm. These will allow you 42 | | to control the amount of time it takes to hash the given password. 43 | | 44 | */ 45 | 46 | 'argon' => [ 47 | 'memory' => 1024, 48 | 'threads' => 2, 49 | 'time' => 2, 50 | ], 51 | 52 | ]; 53 | -------------------------------------------------------------------------------- /app/Exceptions/Handler.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 56 | return response('Sorry, validation failed.', 422); 57 | } 58 | } 59 | 60 | if ($exception instanceof ThrottleException) { 61 | return response($exception->getMessage(), 429); 62 | } 63 | 64 | return parent::render($request, $exception); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/Notifications/YouWereMentioned.php: -------------------------------------------------------------------------------- 1 | reply = $reply; 24 | } 25 | 26 | 27 | /** 28 | * Get the notification's delivery channels. 29 | * 30 | * @param mixed $notifiable 31 | * @return array 32 | */ 33 | public function via($notifiable) 34 | { 35 | return ['database']; 36 | } 37 | 38 | /** 39 | * Get the mail representation of the notification. 40 | * 41 | * @param mixed $notifiable 42 | * @return \Illuminate\Notifications\Messages\MailMessage 43 | */ 44 | public function toMail($notifiable) 45 | { 46 | return (new MailMessage) 47 | ->line('The introduction to the notification.') 48 | ->action('Notification Action', url('/')) 49 | ->line('Thank you for using our application!'); 50 | } 51 | 52 | /** 53 | * Get the array representation of the notification. 54 | * 55 | * @param mixed $notifiable 56 | * @return array 57 | */ 58 | public function toArray($notifiable) 59 | { 60 | return [ 61 | 'message' => $this->reply->owner->name . ' mentioned you in ' . $this->reply->thread->title, 62 | 'link' => $this->reply->path() 63 | ]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | singleton( 30 | Illuminate\Contracts\Http\Kernel::class, 31 | App\Http\Kernel::class 32 | ); 33 | 34 | $app->singleton( 35 | Illuminate\Contracts\Console\Kernel::class, 36 | App\Console\Kernel::class 37 | ); 38 | 39 | $app->singleton( 40 | Illuminate\Contracts\Debug\ExceptionHandler::class, 41 | App\Exceptions\Handler::class 42 | ); 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Return The Application 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This script returns the application instance. The instance is given to 50 | | the calling script so we can separate the building of the instances 51 | | from the actual running of the application and sending responses. 52 | | 53 | */ 54 | 55 | return $app; 56 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_DRIVER', 'null'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Broadcast Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the broadcast connections that will be used 26 | | to broadcast events to other systems or over websockets. Samples of 27 | | each available type of connection are provided inside this array. 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'pusher' => [ 34 | 'driver' => 'pusher', 35 | 'key' => env('PUSHER_APP_KEY'), 36 | 'secret' => env('PUSHER_APP_SECRET'), 37 | 'app_id' => env('PUSHER_APP_ID'), 38 | 'options' => [ 39 | 'cluster' => env('PUSHER_APP_CLUSTER'), 40 | 'useTLS' => true, 41 | ], 42 | ], 43 | 44 | 'redis' => [ 45 | 'driver' => 'redis', 46 | 'connection' => 'default', 47 | ], 48 | 49 | 'log' => [ 50 | 'driver' => 'log', 51 | ], 52 | 53 | 'null' => [ 54 | 'driver' => 'null', 55 | ], 56 | 57 | ], 58 | 59 | ]; 60 | -------------------------------------------------------------------------------- /app/Notifications/ThreadWasUpdated.php: -------------------------------------------------------------------------------- 1 | thread = $thread; 25 | $this->reply = $reply; 26 | } 27 | 28 | /** 29 | * Get the notification's delivery channels. 30 | * 31 | * @param mixed $notifiable 32 | * @return array 33 | */ 34 | public function via($notifiable) 35 | { 36 | return ['database']; 37 | } 38 | 39 | /** 40 | * Get the mail representation of the notification. 41 | * 42 | * @param mixed $notifiable 43 | * @return \Illuminate\Notifications\Messages\MailMessage 44 | */ 45 | public function toMail($notifiable) 46 | { 47 | return (new MailMessage) 48 | ->line('The introduction to the notification.') 49 | ->action('Notification Action', url('/')) 50 | ->line('Thank you for using our application!'); 51 | } 52 | 53 | /** 54 | * Get the array representation of the notification. 55 | * 56 | * @param mixed $notifiable 57 | * @return array 58 | */ 59 | public function toArray($notifiable) 60 | { 61 | return [ 62 | 'message' => $this->reply->owner->name . ' replied to ' . $this->thread->title, 63 | 'link' => $this->reply->path() 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Feature/BestReplyTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 18 | 19 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 20 | 21 | $replies = create(Reply::class, ['thread_id' => $thread->id], 2); 22 | 23 | $this->assertFalse($replies[1]->isBest()); 24 | 25 | $this->postJson(route('best-replies.store', [$replies[1]->id])); 26 | 27 | $this->assertTrue($replies[1]->fresh()->isBest()); 28 | } 29 | 30 | /** @test */ 31 | public function only_the_thread_creator_may_mark_a_reply_as_best() 32 | { 33 | $this->signIn(); 34 | 35 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 36 | 37 | $replies = create(Reply::class, ['thread_id' => $thread->id], 2); 38 | 39 | $this->signIn(create(User::class)); 40 | 41 | $this->postJson(route('best-replies.store', [$replies[1]->id]))->assertStatus(403); 42 | 43 | $this->assertFalse($replies[1]->fresh()->isBest()); 44 | } 45 | 46 | /** @test */ 47 | public function if_a_best_reply_is_deleted_then_the_thread_is_properly_updated_to_reflect_that() 48 | { 49 | \DB::statement('PRAGMA foreign_keys=on;'); 50 | 51 | $this->signIn(); 52 | 53 | $reply = create(Reply::class, ['user_id' => auth()->id()]); 54 | 55 | $reply->thread->markBestReply($reply); 56 | 57 | $this->delete(route('replies.destroy', $reply)); 58 | 59 | $this->assertNull($reply->thread->fresh()->best_reply_id); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 34 | 35 | $status = $kernel->handle( 36 | $input = new Symfony\Component\Console\Input\ArgvInput, 37 | new Symfony\Component\Console\Output\ConsoleOutput 38 | ); 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Shutdown The Application 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Once Artisan has finished running, we will fire off the shutdown events 46 | | so that any final work may be done by the application before we shut 47 | | down the process. This is the last thing to happen to the request. 48 | | 49 | */ 50 | 51 | $kernel->terminate($input, $status); 52 | 53 | exit($status); 54 | -------------------------------------------------------------------------------- /tests/Feature/NotificationTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 16 | } 17 | 18 | /** @test */ 19 | public function a_notification_is_prepared_when_a_subscribed_thread_receives_a_new_reply_that_is_not_by_the_current_user() 20 | { 21 | $thread = create('App\Thread')->subscribe(); 22 | 23 | $this->assertCount(0, auth()->user()->notifications); 24 | 25 | $thread->addReply([ 26 | 'user_id' => auth()->id(), 27 | 'body' => 'Some reply here' 28 | ]); 29 | 30 | $this->assertCount(0, auth()->user()->fresh()->notifications); 31 | 32 | $thread->addReply([ 33 | 'user_id' => create('App\User')->id, 34 | 'body' => 'Some reply here' 35 | ]); 36 | 37 | $this->assertCount(1, auth()->user()->fresh()->notifications); 38 | } 39 | 40 | /** @test */ 41 | public function a_user_can_fetch_their_unread_notifications() 42 | { 43 | create(DatabaseNotification::class); 44 | 45 | $this->assertCount(1, $this->getJson("/profiles/" . auth()->user()->name . "/notifications")->json()); 46 | } 47 | 48 | /** @test */ 49 | public function a_user_can_mark_a_notification_as_read() 50 | { 51 | create(DatabaseNotification::class); 52 | 53 | tap(auth()->user(), function ($user) { 54 | $this->assertCount(1, $user->unreadNotifications); 55 | 56 | $this->delete("/profiles/{$user->name}/notifications/" . $user->unreadNotifications->first()->id); 57 | 58 | $this->assertCount(0, $user->fresh()->unreadNotifications); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Http/Controllers/LockThreadController.php: -------------------------------------------------------------------------------- 1 | update(['locked' => true]); 39 | } 40 | 41 | /** 42 | * Display the specified resource. 43 | * 44 | * @param \App\Thread $thread 45 | * @return Response 46 | */ 47 | public function show(Thread $thread) 48 | { 49 | // 50 | } 51 | 52 | /** 53 | * Show the form for editing the specified resource. 54 | * 55 | * @param \App\Thread $thread 56 | * @return Response 57 | */ 58 | public function edit(Thread $thread) 59 | { 60 | // 61 | } 62 | 63 | /** 64 | * Update the specified resource in storage. 65 | * 66 | * @param \Illuminate\Http\Request $request 67 | * @param \App\Thread $thread 68 | * @return Response 69 | */ 70 | public function update(Request $request, Thread $thread) 71 | { 72 | // 73 | } 74 | 75 | /** 76 | * Unlock the given thread. 77 | * 78 | * @param \App\Thread $thread 79 | * @return void 80 | */ 81 | public function destroy(Thread $thread) 82 | { 83 | $thread->update(['locked' => false]); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /resources/views/threads/_question.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 | 12 | 27 |
28 | 29 |
30 |
31 |
32 | {{ $thread->creator->name }} 37 | {{ $thread->creator->name }} 38 | 39 |
40 |
41 | 42 |
43 |
44 |

45 |
46 |
47 | 48 | 51 |
52 | -------------------------------------------------------------------------------- /app/Activity.php: -------------------------------------------------------------------------------- 1 | id) 37 | ->latest() 38 | ->with('subject') 39 | ->take($take) 40 | ->get() 41 | ->groupBy(function ($activity) { 42 | return $activity->created_at->format('Y-m-d'); 43 | }); 44 | } 45 | 46 | public function subject() { 47 | return $this->morphTo(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Feature/LockThreadTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 17 | 18 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 19 | 20 | $this->post(route('locked-threads.store', $thread))->assertStatus(403); 21 | 22 | $this->assertFalse($thread->fresh()->locked); 23 | } 24 | 25 | /** @test */ 26 | public function administrators_can_lock_threads() 27 | { 28 | $this->signIn(factory(User::class)->states('administrator')->create()); 29 | 30 | $thread = create(Thread::class, ['user_id' => auth()->id()]); 31 | 32 | $this->post(route('locked-threads.store', $thread)); 33 | 34 | $this->assertTrue($thread->fresh()->locked, 'Failed asserting that the thread was locked.'); 35 | } 36 | 37 | /** @test */ 38 | public function administrators_can_unlock_threads() 39 | { 40 | $this->signIn(factory(User::class)->states('administrator')->create()); 41 | 42 | $thread = create(Thread::class, ['user_id' => auth()->id(), 'locked' => true]); 43 | 44 | $this->delete(route('locked-threads.destroy', $thread)); 45 | 46 | $this->assertFalse($thread->fresh()->locked, 'Failed asserting that the thread was unlocked.'); 47 | } 48 | 49 | /** @test */ 50 | public function once_locked_a_thread_may_not_receive_new_replies() 51 | { 52 | $this->signIn(); 53 | 54 | $thread = create('App\Thread', ['locked' => true]); 55 | 56 | $this->post($thread->path() . '/replies', [ 57 | 'body' => 'Foobar', 58 | 'user_id' => auth()->id() 59 | ])->assertStatus(422); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Http/Controllers/ChannelController.php: -------------------------------------------------------------------------------- 1 | mapApiRoutes(); 46 | 47 | $this->mapWebRoutes(); 48 | 49 | // 50 | } 51 | 52 | /** 53 | * Define the "web" routes for the application. 54 | * 55 | * These routes all receive session state, CSRF protection, etc. 56 | * 57 | * @return void 58 | */ 59 | protected function mapWebRoutes() 60 | { 61 | Route::middleware('web') 62 | ->namespace($this->namespace) 63 | ->group(base_path('routes/web.php')); 64 | } 65 | 66 | /** 67 | * Define the "api" routes for the application. 68 | * 69 | * These routes are typically stateless. 70 | * 71 | * @return void 72 | */ 73 | protected function mapApiRoutes() 74 | { 75 | Route::prefix('api') 76 | ->middleware('api') 77 | ->namespace($this->namespace) 78 | ->group(base_path('routes/api.php')); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/Http/Controllers/FavoriteController.php: -------------------------------------------------------------------------------- 1 | favorite(); 40 | } 41 | 42 | /** 43 | * Display the specified resource. 44 | * 45 | * @param \App\Favorite $favorite 46 | * @return \Illuminate\Http\Response 47 | */ 48 | public function show(Favorite $favorite) 49 | { 50 | // 51 | } 52 | 53 | /** 54 | * Show the form for editing the specified resource. 55 | * 56 | * @param \App\Favorite $favorite 57 | * @return \Illuminate\Http\Response 58 | */ 59 | public function edit(Favorite $favorite) 60 | { 61 | // 62 | } 63 | 64 | /** 65 | * Update the specified resource in storage. 66 | * 67 | * @param \Illuminate\Http\Request $request 68 | * @param \App\Favorite $favorite 69 | * @return \Illuminate\Http\Response 70 | */ 71 | public function update(Request $request, Favorite $favorite) 72 | { 73 | // 74 | } 75 | 76 | /** 77 | * Remove the specified resource from storage. 78 | * 79 | * @param \App\Favorite $favorite 80 | * @return \Illuminate\Http\Response 81 | */ 82 | public function destroy(Reply $reply) 83 | { 84 | $reply->unfavorite(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/Http/Controllers/ActivityController.php: -------------------------------------------------------------------------------- 1 | user()->unreadNotifications; 17 | } 18 | 19 | /** 20 | * Show the form for creating a new resource. 21 | * 22 | * @return \Illuminate\Http\Response 23 | */ 24 | public function create() 25 | { 26 | // 27 | } 28 | 29 | /** 30 | * Store a newly created resource in storage. 31 | * 32 | * @param \Illuminate\Http\Request $request 33 | * @return \Illuminate\Http\Response 34 | */ 35 | public function store(Request $request) 36 | { 37 | // 38 | } 39 | 40 | /** 41 | * Display the specified resource. 42 | * 43 | * @param int $id 44 | * @return \Illuminate\Http\Response 45 | */ 46 | public function show($id) 47 | { 48 | // 49 | } 50 | 51 | /** 52 | * Show the form for editing the specified resource. 53 | * 54 | * @param int $id 55 | * @return \Illuminate\Http\Response 56 | */ 57 | public function edit($id) 58 | { 59 | // 60 | } 61 | 62 | /** 63 | * Update the specified resource in storage. 64 | * 65 | * @param \Illuminate\Http\Request $request 66 | * @param int $id 67 | * @return \Illuminate\Http\Response 68 | */ 69 | public function update(Request $request, $id) 70 | { 71 | // 72 | } 73 | 74 | /** 75 | * Remove the specified resource from storage. 76 | * 77 | * @param int $id 78 | * @return \Illuminate\Http\Response 79 | */ 80 | public function destroy($user, $notification) 81 | { 82 | auth()->user()->notifications()->findOrFail($notification)->markAsRead(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /resources/views/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{ config('app.name', 'Laravel') }} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 44 | 50 | 51 | 52 | @stack('head') 53 | 54 | 55 |
56 | @include('layouts._nav') 57 | 58 |
59 | @yield('content') 60 |
61 | 62 | 63 |
64 | @stack('scripts') 65 | 66 | 67 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | define('LARAVEL_START', microtime(true)); 11 | 12 | /* 13 | |-------------------------------------------------------------------------- 14 | | Register The Auto Loadert 15 | |-------------------------------------------------------------------------- 16 | | 17 | | Composer provides a convenient, automatically generated class loader for 18 | | our application. We just need to utilize it! We'll simply require it 19 | | into the script here so that we don't have to worry about manual 20 | | loading any of our classes later on. It feels great to relax. 21 | | 22 | */ 23 | 24 | require __DIR__.'/../vendor/autoload.php'; 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Turn On The Lights 29 | |-------------------------------------------------------------------------- 30 | | 31 | | We need to illuminate PHP development, so let us turn on the lights. 32 | | This bootstraps the framework and gets it ready for use, then it 33 | | will load up this application so that we can run it and send 34 | | the responses back to the browser and delight our users. 35 | | 36 | */ 37 | 38 | $app = require_once __DIR__.'/../bootstrap/app.php'; 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Run The Application 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Once we have the application, we can handle the incoming request 46 | | through the kernel, and send the associated response back to 47 | | the client's browser allowing them to enjoy the creative 48 | | and wonderful application we have prepared for them. 49 | | 50 | */ 51 | 52 | $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); 53 | 54 | $response = $kernel->handle( 55 | $request = Illuminate\Http\Request::capture() 56 | ); 57 | 58 | $response->send(); 59 | 60 | $kernel->terminate($request, $response); 61 | -------------------------------------------------------------------------------- /app/Http/Controllers/ProfileController.php: -------------------------------------------------------------------------------- 1 | $user, 52 | 'activities' => Activity::feed($user), 53 | ]); 54 | } 55 | 56 | /** 57 | * Show the form for editing the specified resource. 58 | * 59 | * @param User $user 60 | * @return void 61 | */ 62 | public function edit(User $user) 63 | { 64 | // 65 | } 66 | 67 | /** 68 | * Update the specified resource in storage. 69 | * 70 | * @param \Illuminate\Http\Request $request 71 | * @param User $user 72 | * @return void 73 | */ 74 | public function update(Request $request, User $user) 75 | { 76 | // 77 | } 78 | 79 | /** 80 | * Remove the specified resource from storage. 81 | * 82 | * @param User $user 83 | * @return void 84 | */ 85 | public function destroy(User $user) 86 | { 87 | // 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/Http/Controllers/BestReplyController.php: -------------------------------------------------------------------------------- 1 | authorize('update', $reply->thread); 39 | 40 | $reply->thread->markBestReply($reply); 41 | } 42 | 43 | /** 44 | * Display the specified resource. 45 | * 46 | * @param \App\Reply $reply 47 | * @return \Illuminate\Http\Response 48 | */ 49 | public function show(Reply $reply) 50 | { 51 | // 52 | } 53 | 54 | /** 55 | * Show the form for editing the specified resource. 56 | * 57 | * @param \App\Reply $reply 58 | * @return \Illuminate\Http\Response 59 | */ 60 | public function edit(Reply $reply) 61 | { 62 | // 63 | } 64 | 65 | /** 66 | * Update the specified resource in storage. 67 | * 68 | * @param \Illuminate\Http\Request $request 69 | * @param \App\Reply $reply 70 | * @return \Illuminate\Http\Response 71 | */ 72 | public function update(Request $request, Reply $reply) 73 | { 74 | // 75 | } 76 | 77 | /** 78 | * Remove the specified resource from storage. 79 | * 80 | * @param \App\Reply $reply 81 | * @return \Illuminate\Http\Response 82 | */ 83 | public function destroy(Reply $reply) 84 | { 85 | // 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /resources/views/auth/passwords/email.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |
8 |
{{ __('Reset Password') }}
9 | 10 |
11 | @if (session('status')) 12 | 15 | @endif 16 | 17 |
18 | @csrf 19 | 20 |
21 | 22 | 23 |
24 | 25 | 26 | @error('email') 27 | 28 | {{ $message }} 29 | 30 | @enderror 31 |
32 |
33 | 34 |
35 |
36 | 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | @endsection 48 | -------------------------------------------------------------------------------- /resources/views/threads/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 | @include ('threads._list') 8 | 9 | {{ $threads->links() }} 10 |
11 |
12 |
13 |
14 | Search 15 |
16 | 17 |
18 |
19 |
20 | 21 |
22 | 23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 | @if (count($trending)) 31 |
32 |
33 | Trending threads 34 |
35 |
36 | 45 |
46 |
47 | @endif 48 | 49 |
50 |
51 |
52 | @endsection 53 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | require 'json' 5 | require 'yaml' 6 | 7 | VAGRANTFILE_API_VERSION ||= "2" 8 | confDir = $confDir ||= File.expand_path("vendor/laravel/homestead", File.dirname(__FILE__)) 9 | 10 | homesteadYamlPath = File.expand_path("Homestead.yaml", File.dirname(__FILE__)) 11 | homesteadJsonPath = File.expand_path("Homestead.json", File.dirname(__FILE__)) 12 | afterScriptPath = "after.sh" 13 | customizationScriptPath = "user-customizations.sh" 14 | aliasesPath = "aliases" 15 | 16 | require File.expand_path(confDir + '/scripts/homestead.rb') 17 | 18 | Vagrant.require_version '>= 2.2.4' 19 | 20 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 21 | if File.exist? aliasesPath then 22 | config.vm.provision "file", source: aliasesPath, destination: "/tmp/bash_aliases" 23 | config.vm.provision "shell" do |s| 24 | s.inline = "awk '{ sub(\"\r$\", \"\"); print }' /tmp/bash_aliases > /home/vagrant/.bash_aliases" 25 | end 26 | end 27 | 28 | if File.exist? homesteadYamlPath then 29 | settings = YAML::load(File.read(homesteadYamlPath)) 30 | elsif File.exist? homesteadJsonPath then 31 | settings = JSON::parse(File.read(homesteadJsonPath)) 32 | else 33 | abort "Homestead settings file not found in " + File.dirname(__FILE__) 34 | end 35 | 36 | Homestead.configure(config, settings) 37 | 38 | if File.exist? afterScriptPath then 39 | config.vm.provision "shell", path: afterScriptPath, privileged: false, keep_color: true 40 | end 41 | 42 | if File.exist? customizationScriptPath then 43 | config.vm.provision "shell", path: customizationScriptPath, privileged: false, keep_color: true 44 | end 45 | 46 | if Vagrant.has_plugin?('vagrant-hostsupdater') 47 | config.hostsupdater.aliases = settings['sites'].map { |site| site['map'] } 48 | elsif Vagrant.has_plugin?('vagrant-hostmanager') 49 | config.hostmanager.enabled = true 50 | config.hostmanager.manage_host = true 51 | config.hostmanager.aliases = settings['sites'].map { |site| site['map'] } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/Policies/UserPolicy.php: -------------------------------------------------------------------------------- 1 | is($model); 56 | } 57 | 58 | /** 59 | * Determine whether the user can delete the model. 60 | * 61 | * @param \App\User $user 62 | * @param \App\User $model 63 | * @return mixed 64 | */ 65 | public function delete(User $user, User $model) 66 | { 67 | // 68 | } 69 | 70 | /** 71 | * Determine whether the user can restore the model. 72 | * 73 | * @param \App\User $user 74 | * @param \App\User $model 75 | * @return mixed 76 | */ 77 | public function restore(User $user, User $model) 78 | { 79 | // 80 | } 81 | 82 | /** 83 | * Determine whether the user can permanently delete the model. 84 | * 85 | * @param \App\User $user 86 | * @param \App\User $model 87 | * @return mixed 88 | */ 89 | public function forceDelete(User $user, User $model) 90 | { 91 | // 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /resources/js/components/NewReply.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 70 | -------------------------------------------------------------------------------- /app/ThreadSubscription.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 40 | } 41 | 42 | /** 43 | * Get the thread associated with the subscription. 44 | * 45 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 46 | */ 47 | public function thread() 48 | { 49 | return $this->belongsTo(Thread::class); 50 | } 51 | 52 | /** 53 | * Notify the related user that the thread was updated. 54 | * 55 | * @param \App\Reply $reply 56 | */ 57 | public function notify($reply) 58 | { 59 | $this->user->notify(new ThreadWasUpdated($this->thread, $reply)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Http/Controllers/SubscriptionController.php: -------------------------------------------------------------------------------- 1 | subscribe(); 40 | } 41 | 42 | /** 43 | * Display the specified resource. 44 | * 45 | * @param \App\ThreadSubscription $threadSubscription 46 | * @return \Illuminate\Http\Response 47 | */ 48 | public function show(ThreadSubscription $threadSubscription) 49 | { 50 | // 51 | } 52 | 53 | /** 54 | * Show the form for editing the specified resource. 55 | * 56 | * @param \App\ThreadSubscription $threadSubscription 57 | * @return \Illuminate\Http\Response 58 | */ 59 | public function edit(ThreadSubscription $threadSubscription) 60 | { 61 | // 62 | } 63 | 64 | /** 65 | * Update the specified resource in storage. 66 | * 67 | * @param Request $request 68 | * @param \App\ThreadSubscription $threadSubscription 69 | * @return \Illuminate\Http\Response 70 | */ 71 | public function update(Request $request, ThreadSubscription $threadSubscription) 72 | { 73 | // 74 | } 75 | 76 | /** 77 | * Remove the specified resource from storage. 78 | * 79 | * @param \App\ThreadSubscription $threadSubscription 80 | * @return \Illuminate\Http\Response 81 | */ 82 | public function destroy($channel, Thread $thread) 83 | { 84 | $thread->unsubscribe(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * First we will load all of this project's JavaScript dependencies which 3 | * includes Vue and other libraries. It is a great starting point when 4 | * building robust, powerful web applications using Vue and Laravel. 5 | */ 6 | 7 | require('./bootstrap'); 8 | 9 | window.Vue = require('vue'); 10 | 11 | import InstantSearch from 'vue-instantsearch'; 12 | 13 | Vue.use(InstantSearch); 14 | 15 | 16 | let authorizations = require('./authorization'); 17 | 18 | Vue.prototype.authorize = function (...params) { 19 | if (! window.app.signedIn) return false; 20 | 21 | if (typeof params[0] === 'string') { 22 | return authorizations[params[0]](params[1]); 23 | } 24 | 25 | return params[0](window.app.user); 26 | }; 27 | 28 | Vue.prototype.signedIn = window.app.signedIn; 29 | 30 | /** 31 | * The following block of code may be used to automatically register your 32 | * Vue components. It will recursively scan this directory for the Vue 33 | * components and automatically register them with their "basename". 34 | * 35 | * Eg. ./components/Flash.vue -> 36 | */ 37 | 38 | // const files = require.context('./', true, /\.vue$/i) 39 | // files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default)) 40 | 41 | Vue.component('flash', require('./components/Flash.vue').default); 42 | Vue.component('paginator', require('./components/Paginator.vue').default); 43 | Vue.component('user-notifications', require('./components/UserNotifications.vue').default); 44 | Vue.component('avatar-form', require('./components/AvatarForm.vue').default); 45 | Vue.component('thread-view', require('./pages/Thread.vue').default); 46 | Vue.component('wysiwyg', require('./components/WYSIWYG.vue').default); 47 | 48 | /** 49 | * Next, we will create a fresh Vue application instance and attach it to 50 | * the page. Then, you may begin adding components to this application 51 | * or customize the JavaScript scaffolding to fit your unique needs. 52 | */ 53 | 54 | window.events = new Vue(); 55 | 56 | window.flash = function (message, level = 'success') { 57 | window.events.$emit('flash', { message, level }); 58 | }; 59 | 60 | const app = new Vue({ 61 | el: '#app', 62 | }); 63 | -------------------------------------------------------------------------------- /app/Policies/ThreadPolicy.php: -------------------------------------------------------------------------------- 1 | is($thread->creator); 57 | } 58 | 59 | /** 60 | * Determine whether the user can delete the thread. 61 | * 62 | * @param \App\User $user 63 | * @param \App\Thread $thread 64 | * @return mixed 65 | */ 66 | public function delete(User $user, Thread $thread) 67 | { 68 | // 69 | } 70 | 71 | /** 72 | * Determine whether the user can restore the thread. 73 | * 74 | * @param \App\User $user 75 | * @param \App\Thread $thread 76 | * @return mixed 77 | */ 78 | public function restore(User $user, Thread $thread) 79 | { 80 | // 81 | } 82 | 83 | /** 84 | * Determine whether the user can permanently delete the thread. 85 | * 86 | * @param \App\User $user 87 | * @param \App\Thread $thread 88 | * @return mixed 89 | */ 90 | public function forceDelete(User $user, Thread $thread) 91 | { 92 | // 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/RegisterController.php: -------------------------------------------------------------------------------- 1 | middleware('guest'); 42 | } 43 | 44 | /** 45 | * Get a validator for an incoming registration request. 46 | * 47 | * @param array $data 48 | * @return \Illuminate\Contracts\Validation\Validator 49 | */ 50 | protected function validator(array $data) 51 | { 52 | return Validator::make($data, [ 53 | 'name' => ['required', 'string', 'max:255'], 54 | 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 55 | 'password' => ['required', 'string', 'min:8', 'confirmed'], 56 | ]); 57 | } 58 | 59 | /** 60 | * Create a new user instance after a valid registration. 61 | * 62 | * @param array $data 63 | * @return \App\User 64 | */ 65 | protected function create(array $data) 66 | { 67 | return User::create([ 68 | 'name' => $data['name'], 69 | 'email' => $data['email'], 70 | 'password' => Hash::make($data['password']), 71 | ]); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /resources/views/auth/passwords/confirm.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |
8 |
{{ __('Confirm Password') }}
9 | 10 |
11 | {{ __('Please confirm your password before continuing.') }} 12 | 13 |
14 | @csrf 15 | 16 |
17 | 18 | 19 |
20 | 21 | 22 | @error('password') 23 | 24 | {{ $message }} 25 | 26 | @enderror 27 |
28 |
29 | 30 |
31 |
32 | 35 | 36 | @if (Route::has('password.request')) 37 | 38 | {{ __('Forgot Your Password?') }} 39 | 40 | @endif 41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | @endsection 50 | -------------------------------------------------------------------------------- /app/Policies/ReplyPolicy.php: -------------------------------------------------------------------------------- 1 | fresh()->lastReply) { 45 | return true; 46 | } 47 | 48 | return ! $lastReply->wasJustPublished(); 49 | } 50 | 51 | /** 52 | * Determine whether the user can update the reply. 53 | * 54 | * @param \App\User $user 55 | * @param \App\Reply $reply 56 | * @return mixed 57 | */ 58 | public function update(User $user, Reply $reply) 59 | { 60 | return $user->is($reply->owner); 61 | } 62 | 63 | /** 64 | * Determine whether the user can delete the reply. 65 | * 66 | * @param \App\User $user 67 | * @param \App\Reply $reply 68 | * @return mixed 69 | */ 70 | public function delete(User $user, Reply $reply) 71 | { 72 | // 73 | } 74 | 75 | /** 76 | * Determine whether the user can restore the reply. 77 | * 78 | * @param \App\User $user 79 | * @param \App\Reply $reply 80 | * @return mixed 81 | */ 82 | public function restore(User $user, Reply $reply) 83 | { 84 | // 85 | } 86 | 87 | /** 88 | * Determine whether the user can permanently delete the reply. 89 | * 90 | * @param \App\User $user 91 | * @param \App\Reply $reply 92 | * @return mixed 93 | */ 94 | public function forceDelete(User $user, Reply $reply) 95 | { 96 | // 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Unit/ReplyTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(User::class, create(Reply::class)->owner); 18 | } 19 | 20 | /** @test */ 21 | public function has_a_favorites() 22 | { 23 | $reply = create(Reply::class); 24 | $favorite = create(Favorite::class, ['favorable_id' => $reply->id]); 25 | 26 | $this->assertTrue($reply->favorites->contains($favorite)); 27 | } 28 | 29 | /** @test */ 30 | public function it_knows_if_it_was_just_published() 31 | { 32 | $reply = create(Reply::class); 33 | 34 | $this->assertTrue($reply->wasJustPublished()); 35 | 36 | $reply->created_at = Carbon::now()->subMonth(); 37 | 38 | $this->assertFalse($reply->wasJustPublished()); 39 | } 40 | 41 | /** @test */ 42 | public function it_can_detect_all_mentioned_users_in_the_body() 43 | { 44 | $reply = new Reply([ 45 | 'body' => '@JaneDoe wants to talk to @JohnDoe' 46 | ]); 47 | 48 | $this->assertEquals(['JaneDoe', 'JohnDoe'], $reply->mentionedUsers()); 49 | } 50 | 51 | /** @test */ 52 | public function it_wraps_mentioned_usernames_in_the_body_within_anchor_tags() 53 | { 54 | $reply = new Reply([ 55 | 'body' => 'Hello @Jane-Doe.' 56 | ]); 57 | 58 | $this->assertEquals( 59 | 'Hello @Jane-Doe.', 60 | $reply->body 61 | ); 62 | } 63 | 64 | /** @test */ 65 | public function it_knows_if_it_is_the_best_reply() 66 | { 67 | $reply = create(Reply::class); 68 | 69 | $this->assertFalse($reply->isBest()); 70 | 71 | $reply->thread->update(['best_reply_id' => $reply->id]); 72 | 73 | $this->assertTrue($reply->fresh()->isBest()); 74 | } 75 | 76 | /** @test */ 77 | public function it_sanitizes_body_automatically() 78 | { 79 | $reply = make(Reply::class, [ 80 | 'body' => '

This is okay.

' 81 | ]); 82 | 83 | $this->assertEquals('

This is okay.

', $reply->body); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/laravel", 3 | "type": "project", 4 | "description": "The Laravel Framework.", 5 | "keywords": [ 6 | "framework", 7 | "laravel" 8 | ], 9 | "license": "MIT", 10 | "require": { 11 | "php": "^7.4", 12 | "ext-json": "*", 13 | "algolia/algoliasearch-client-php": "^2.2", 14 | "fideloper/proxy": "^4.2", 15 | "fruitcake/laravel-cors": "^1.0", 16 | "guzzlehttp/guzzle": "^6.3", 17 | "kitetail/zttp": "^0.6.0", 18 | "laravel/framework": "^7.0", 19 | "laravel/scout": "^8.0", 20 | "laravel/tinker": "^2.0", 21 | "laravel/ui": "^2.0", 22 | "predis/predis": "^1.1", 23 | "stevebauman/purify": "^3.0" 24 | }, 25 | "require-dev": { 26 | "barryvdh/laravel-debugbar": "^3.2", 27 | "barryvdh/laravel-ide-helper": "^2.6", 28 | "facade/ignition": "^2.0", 29 | "fzaninotto/faker": "^1.9.1", 30 | "laravel/homestead": "^10.7", 31 | "mockery/mockery": "^1.3.1", 32 | "nunomaduro/collision": "^4.1", 33 | "phpunit/phpunit": "^8.5" 34 | }, 35 | "config": { 36 | "optimize-autoloader": true, 37 | "preferred-install": "dist", 38 | "sort-packages": true 39 | }, 40 | "extra": { 41 | "laravel": { 42 | "dont-discover": [] 43 | } 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "App\\": "app/" 48 | }, 49 | "classmap": [ 50 | "database/seeds", 51 | "database/factories" 52 | ] 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Tests\\": "tests/" 57 | }, 58 | "files": [ 59 | "tests/utilities/functions.php" 60 | ] 61 | }, 62 | "minimum-stability": "dev", 63 | "prefer-stable": true, 64 | "scripts": { 65 | "post-autoload-dump": [ 66 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", 67 | "@php artisan package:discover --ansi" 68 | ], 69 | "post-root-package-install": [ 70 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 71 | ], 72 | "post-create-project-cmd": [ 73 | "@php artisan key:generate --ansi" 74 | ], 75 | "post-update-cmd": [ 76 | "Illuminate\\Foundation\\ComposerScripts::postUpdate", 77 | "@php artisan ide-helper:generate", 78 | "@php artisan ide-helper:meta" 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/Http/Kernel.php: -------------------------------------------------------------------------------- 1 | [ 32 | \App\Http\Middleware\EncryptCookies::class, 33 | \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, 34 | \Illuminate\Session\Middleware\StartSession::class, 35 | // \Illuminate\Session\Middleware\AuthenticateSession::class, 36 | \Illuminate\View\Middleware\ShareErrorsFromSession::class, 37 | \App\Http\Middleware\VerifyCsrfToken::class, 38 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 39 | ], 40 | 41 | 'api' => [ 42 | 'throttle:60,1', 43 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 44 | ], 45 | ]; 46 | 47 | /** 48 | * The application's route middleware. 49 | * 50 | * These middleware may be assigned to groups or used individually. 51 | * 52 | * @var array 53 | */ 54 | protected $routeMiddleware = array( 55 | 'auth' => \App\Http\Middleware\Authenticate::class, 56 | 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 57 | 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 58 | 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 59 | 'can' => \Illuminate\Auth\Middleware\Authorize::class, 60 | 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 61 | 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 62 | 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 63 | 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 64 | 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 65 | 'admin' => \App\Http\Middleware\Administrator::class 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /resources/views/threads/search.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 | 10 |
11 | 12 | 19 | 20 |
21 | 22 |
23 |
24 |
25 | Search 26 |
27 | 28 |
29 | 30 | 33 | 34 |
35 |
36 | 37 |
38 |
39 | Filter By Channel 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 | @if (count($trending)) 48 |
49 |
50 | Trending Threads 51 |
52 | 53 |
54 | 63 |
64 |
65 | @endif 66 |
67 |
68 |
69 | @endsection 70 | -------------------------------------------------------------------------------- /resources/views/threads/create.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @push('head') 4 | 5 | @endpush 6 | 7 | @section('content') 8 |
9 |
10 |
11 |
12 |
Create a thread
13 | 14 |
15 |
16 | @csrf 17 | 18 |
19 | 20 | 26 |
27 | 28 |
29 | 30 | 37 |
38 | 39 |
40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 |
48 | 49 | @if ($errors->{ $bag ?? 'default' }->any()) 50 |
51 | @foreach ($errors->{ $bag ?? 'default' }->all() as $error) 52 |

{{ $error }}

53 | @endforeach 54 |
55 | @endif 56 |
57 |
58 |
59 |
60 |
61 | @endsection 62 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DRIVER', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Default Cloud Filesystem Disk 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Many applications store files both locally and in the cloud. For this 24 | | reason, you may specify a default "cloud" driver here. This driver 25 | | will be bound as the Cloud disk implementation in the container. 26 | | 27 | */ 28 | 29 | 'cloud' => env('FILESYSTEM_CLOUD', 's3'), 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Filesystem Disks 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Here you may configure as many filesystem "disks" as you wish, and you 37 | | may even configure multiple disks of the same driver. Defaults have 38 | | been setup for each driver as an example of the required options. 39 | | 40 | | Supported Drivers: "local", "ftp", "sftp", "s3" 41 | | 42 | */ 43 | 44 | 'disks' => [ 45 | 46 | 'local' => [ 47 | 'driver' => 'local', 48 | 'root' => storage_path('app'), 49 | ], 50 | 51 | 'public' => [ 52 | 'driver' => 'local', 53 | 'root' => storage_path('app/public'), 54 | 'url' => env('APP_URL').'/storage', 55 | 'visibility' => 'public', 56 | ], 57 | 58 | 's3' => [ 59 | 'driver' => 's3', 60 | 'key' => env('AWS_ACCESS_KEY_ID'), 61 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 62 | 'region' => env('AWS_DEFAULT_REGION'), 63 | 'bucket' => env('AWS_BUCKET'), 64 | 'endpoint' => env('AWS_URL'), 65 | ], 66 | 67 | ], 68 | 69 | /* 70 | |-------------------------------------------------------------------------- 71 | | Symbolic Links 72 | |-------------------------------------------------------------------------- 73 | | 74 | | Here you may configure the symbolic links that will be created when the 75 | | `storage:link` Artisan command is executed. The array keys should be 76 | | the locations of the links and the values should be their targets. 77 | | 78 | */ 79 | 80 | 'links' => [ 81 | public_path('storage') => storage_path('app/public'), 82 | ], 83 | 84 | ]; 85 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | true]); 16 | Route::get('/', fn () => view('welcome')); 17 | Route::get('/home', 'HomeController@index')->name('home'); 18 | Route::get('threads/search', 'SearchController@show'); 19 | 20 | Route::group(['prefix' => 'threads'], function () { 21 | Route::get('', 'ThreadController@index')->name('threads'); 22 | Route::name('threads.')->group(function () { 23 | Route::resource('', 'ThreadController')->parameters(['' => 'thread'])->only(['create', 'edit'])->middleware('auth'); 24 | Route::get('{channel}/{thread}', 'ThreadController@show')->name('show'); 25 | Route::post('', 'ThreadController@store')->name('store')->middleware(['verified', 'auth']); 26 | Route::delete('{channel}/{thread}', 'ThreadController@destroy')->name('destroy')->middleware('auth'); 27 | Route::patch('{channel}/{thread}', 'ThreadController@update'); 28 | }); 29 | 30 | Route::post('{channel}/{thread}/subscriptions', 'SubscriptionController@store')->middleware('auth'); 31 | Route::delete('{channel}/{thread}/subscriptions', 'SubscriptionController@destroy')->middleware('auth'); 32 | 33 | Route::get('{channel}/{thread}/replies', 'ReplyController@index'); 34 | Route::post('{channel}/{thread}/replies', 'ReplyController@store')->name('replies.store')->middleware('auth'); 35 | Route::get('{channel}', 'ThreadController@index')->name('thread.index'); 36 | }); 37 | 38 | Route::group(['middleware' => 'auth', 'prefix' => 'replies/{reply}'], function () { 39 | Route::post('/favorites', 'FavoriteController@store'); 40 | Route::delete('/favorites', 'FavoriteController@destroy'); 41 | Route::post('/best', 'BestReplyController@store')->name('best-replies.store'); 42 | Route::delete('', 'ReplyController@destroy')->name('replies.destroy'); 43 | Route::patch('', 'ReplyController@update'); 44 | }); 45 | 46 | Route::group(['prefix' => 'profiles/{user}'], function () { 47 | Route::get('', 'ProfileController@show')->name('user.profile'); 48 | Route::get('notifications', 'UserNotificationController@index'); 49 | Route::delete('/notifications/{notification}', 'UserNotificationController@destroy')->middleware('auth'); 50 | }); 51 | 52 | Route::get('api/users', 'Api\UserController@index'); 53 | Route::post('api/users/{user}/avatars', 'Api\UserAvatarController@store')->middleware('auth')->name('avatars'); 54 | 55 | Route::post('locked-threads/{thread}', 'LockThreadController@store')->name('locked-threads.store')->middleware('admin'); 56 | Route::delete('locked-threads/{thread}', 'LockThreadController@destroy')->name('locked-threads.destroy')->middleware('admin'); 57 | -------------------------------------------------------------------------------- /config/queue.php: -------------------------------------------------------------------------------- 1 | env('QUEUE_CONNECTION', 'sync'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Queue Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure the connection information for each server that 24 | | is used by your application. A default configuration has been added 25 | | for each back-end shipped with Laravel. You are free to add more. 26 | | 27 | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'sync' => [ 34 | 'driver' => 'sync', 35 | ], 36 | 37 | 'database' => [ 38 | 'driver' => 'database', 39 | 'table' => 'jobs', 40 | 'queue' => 'default', 41 | 'retry_after' => 90, 42 | ], 43 | 44 | 'beanstalkd' => [ 45 | 'driver' => 'beanstalkd', 46 | 'host' => 'localhost', 47 | 'queue' => 'default', 48 | 'retry_after' => 90, 49 | 'block_for' => 0, 50 | ], 51 | 52 | 'sqs' => [ 53 | 'driver' => 'sqs', 54 | 'key' => env('AWS_ACCESS_KEY_ID'), 55 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 56 | 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 57 | 'queue' => env('SQS_QUEUE', 'your-queue-name'), 58 | 'suffix' => env('SQS_SUFFIX'), 59 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 60 | ], 61 | 62 | 'redis' => [ 63 | 'driver' => 'redis', 64 | 'connection' => 'default', 65 | 'queue' => env('REDIS_QUEUE', 'default'), 66 | 'retry_after' => 90, 67 | 'block_for' => null, 68 | ], 69 | 70 | ], 71 | 72 | /* 73 | |-------------------------------------------------------------------------- 74 | | Failed Queue Jobs 75 | |-------------------------------------------------------------------------- 76 | | 77 | | These options configure the behavior of failed queue job logging so you 78 | | can control which database and table are used to store the jobs that 79 | | have failed. You may change them to any database / table you wish. 80 | | 81 | */ 82 | 83 | 'failed' => [ 84 | 'driver' => env('QUEUE_FAILED_DRIVER', 'database'), 85 | 'database' => env('DB_CONNECTION', 'mysql'), 86 | 'table' => 'failed_jobs', 87 | ], 88 | 89 | ]; 90 | -------------------------------------------------------------------------------- /app/Http/Controllers/ReplyController.php: -------------------------------------------------------------------------------- 1 | replies()->paginate(10); 21 | } 22 | 23 | /** 24 | * Show the form for creating a new resource. 25 | * 26 | * @return \Illuminate\Http\Response 27 | */ 28 | public function create() 29 | { 30 | // 31 | } 32 | 33 | /** 34 | * Store a newly created resource in storage. 35 | * 36 | * @param $channelId 37 | * @param Thread $thread 38 | * @param CreatePostRequest $request 39 | * @return array|ResponseFactory|\Illuminate\Database\Eloquent\Model|\Illuminate\Http\Response 40 | */ 41 | public function store($channelId, Thread $thread, CreatePostRequest $request) 42 | { 43 | if ($thread->locked) { 44 | return response('Thread is locked', 422); 45 | } 46 | 47 | return $thread->addReply([ 48 | 'body' => request('body'), 49 | 'user_id' => auth()->id() 50 | ])->load('owner'); 51 | } 52 | 53 | /** 54 | * Display the specified resource. 55 | * 56 | * @param \App\Reply $reply 57 | * @return \Illuminate\Http\Response 58 | */ 59 | public function show(Reply $reply) 60 | { 61 | // 62 | } 63 | 64 | /** 65 | * Show the form for editing the specified resource. 66 | * 67 | * @param \App\Reply $reply 68 | * @return \Illuminate\Http\Response 69 | */ 70 | public function edit(Reply $reply) 71 | { 72 | // 73 | } 74 | 75 | /** 76 | * Update the specified resource in storage. 77 | * 78 | * @param \App\Reply $reply 79 | * @return void 80 | * @throws \Illuminate\Auth\Access\AuthorizationException 81 | */ 82 | public function update(Reply $reply) 83 | { 84 | $this->authorize('update', $reply); 85 | 86 | request()->validate([ 87 | 'body' => ['required', new SpamFree] 88 | ]); 89 | 90 | $reply->update(request(['body'])); 91 | } 92 | 93 | /** 94 | * Remove the specified resource from storage. 95 | * 96 | * @param \App\Reply $reply 97 | * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response 98 | * @throws \Illuminate\Auth\Access\AuthorizationException 99 | */ 100 | public function destroy(Reply $reply) 101 | { 102 | $this->authorize('update', $reply); 103 | 104 | $reply->delete(); 105 | 106 | if (request()->expectsJson()) { 107 | return response(['status' => 'Reply deleted']); 108 | } 109 | 110 | return back(); 111 | } 112 | } 113 | --------------------------------------------------------------------------------