Browse Source

Initial commit

master
Richard Dern 7 months ago
commit
37b36c9f55
213 changed files with 31948 additions and 0 deletions
  1. +15
    -0
      .editorconfig
  2. +51
    -0
      .env.example
  3. +5
    -0
      .gitattributes
  4. +16
    -0
      .gitignore
  5. +13
    -0
      .styleci.yml
  6. +40
    -0
      README.md
  7. +59
    -0
      app/Console/Commands/UpdateDocuments.php
  8. +59
    -0
      app/Console/Commands/UpdateFeeds.php
  9. +42
    -0
      app/Console/Kernel.php
  10. +55
    -0
      app/Exceptions/Handler.php
  11. +10
    -0
      app/Exceptions/UserDoesNotExistsException.php
  12. +40
    -0
      app/Http/Controllers/Auth/ConfirmPasswordController.php
  13. +22
    -0
      app/Http/Controllers/Auth/ForgotPasswordController.php
  14. +40
    -0
      app/Http/Controllers/Auth/LoginController.php
  15. +73
    -0
      app/Http/Controllers/Auth/RegisterController.php
  16. +30
    -0
      app/Http/Controllers/Auth/ResetPasswordController.php
  17. +42
    -0
      app/Http/Controllers/Auth/VerificationController.php
  18. +13
    -0
      app/Http/Controllers/Controller.php
  19. +188
    -0
      app/Http/Controllers/DocumentController.php
  20. +123
    -0
      app/Http/Controllers/FeedController.php
  21. +136
    -0
      app/Http/Controllers/FeedItemController.php
  22. +85
    -0
      app/Http/Controllers/FeedItemStateController.php
  23. +133
    -0
      app/Http/Controllers/FolderController.php
  24. +244
    -0
      app/Http/Controllers/HomeController.php
  25. +67
    -0
      app/Http/Kernel.php
  26. +21
    -0
      app/Http/Middleware/Authenticate.php
  27. +17
    -0
      app/Http/Middleware/CheckForMaintenanceMode.php
  28. +17
    -0
      app/Http/Middleware/EncryptCookies.php
  29. +27
    -0
      app/Http/Middleware/RedirectIfAuthenticated.php
  30. +18
    -0
      app/Http/Middleware/TrimStrings.php
  31. +20
    -0
      app/Http/Middleware/TrustHosts.php
  32. +23
    -0
      app/Http/Middleware/TrustProxies.php
  33. +17
    -0
      app/Http/Middleware/VerifyCsrfToken.php
  34. +36
    -0
      app/Http/Requests/Documents/StoreRequest.php
  35. +36
    -0
      app/Http/Requests/Folders/StoreRequest.php
  36. +41
    -0
      app/Http/Requests/Folders/UpdateRequest.php
  37. +36
    -0
      app/Http/Requests/StoreAccountRequest.php
  38. +49
    -0
      app/Jobs/EnqueueDocumentUpdate.php
  39. +49
    -0
      app/Jobs/EnqueueFeedUpdate.php
  40. +30
    -0
      app/Models/Bookmark.php
  41. +219
    -0
      app/Models/Document.php
  42. +181
    -0
      app/Models/Feed.php
  43. +44
    -0
      app/Models/FeedItem.php
  44. +26
    -0
      app/Models/FeedItemState.php
  45. +229
    -0
      app/Models/Folder.php
  46. +34
    -0
      app/Models/IgnoredFeed.php
  47. +65
    -0
      app/Models/Observers/DocumentObserver.php
  48. +64
    -0
      app/Models/Observers/FeedObserver.php
  49. +19
    -0
      app/Models/Observers/UserObserver.php
  50. +94
    -0
      app/Models/Policies/FolderPolicy.php
  51. +669
    -0
      app/Models/Traits/Document/AnalysesDocument.php
  52. +219
    -0
      app/Models/Traits/Feed/AnalysesFeed.php
  53. +64
    -0
      app/Models/Traits/Folder/BuildsTree.php
  54. +82
    -0
      app/Models/Traits/Folder/CreatesDefaultFolders.php
  55. +95
    -0
      app/Models/User.php
  56. +59
    -0
      app/Notifications/DocumentUpdated.php
  57. +59
    -0
      app/Notifications/FeedUpdated.php
  58. +59
    -0
      app/Notifications/UnreadItemsChanged.php
  59. +28
    -0
      app/Providers/AppServiceProvider.php
  60. +31
    -0
      app/Providers/AuthServiceProvider.php
  61. +21
    -0
      app/Providers/BroadcastServiceProvider.php
  62. +34
    -0
      app/Providers/EventServiceProvider.php
  63. +42
    -0
      app/Providers/HorizonServiceProvider.php
  64. +36
    -0
      app/Providers/LangServiceProvider.php
  65. +26
    -0
      app/Providers/ObserversServiceProvider.php
  66. +82
    -0
      app/Providers/RouteServiceProvider.php
  67. +53
    -0
      artisan
  68. +55
    -0
      bootstrap/app.php
  69. +2
    -0
      bootstrap/cache/.gitignore
  70. +71
    -0
      composer.json
  71. +8596
    -0
      composer.lock
  72. +240
    -0
      config/app.php
  73. +117
    -0
      config/auth.php
  74. +62
    -0
      config/broadcasting.php
  75. +104
    -0
      config/cache.php
  76. +34
    -0
      config/cors.php
  77. +46
    -0
      config/cyca.php
  78. +147
    -0
      config/database.php
  79. +85
    -0
      config/filesystems.php
  80. +52
    -0
      config/hashing.php
  81. +190
    -0
      config/horizon.php
  82. +104
    -0
      config/logging.php
  83. +110
    -0
      config/mail.php
  84. +89
    -0
      config/queue.php
  85. +33
    -0
      config/services.php
  86. +201
    -0
      config/session.php
  87. +36
    -0
      config/view.php
  88. +2
    -0
      database/.gitignore
  89. +28
    -0
      database/factories/UserFactory.php
  90. +35
    -0
      database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php
  91. +37
    -0
      database/migrations/2014_10_12_000000_create_users_table.php
  92. +32
    -0
      database/migrations/2014_10_12_100000_create_password_resets_table.php
  93. +36
    -0
      database/migrations/2019_08_19_000000_create_failed_jobs_table.php
  94. +32
    -0
      database/migrations/2020_08_19_194845_create_cache_table.php
  95. +35
    -0
      database/migrations/2020_08_19_194901_create_notifications_table.php
  96. +36
    -0
      database/migrations/2020_08_19_194910_create_jobs_table.php
  97. +35
    -0
      database/migrations/2020_08_19_194916_create_sessions_table.php
  98. +38
    -0
      database/migrations/2020_08_19_200056_create_folders_table.php
  99. +38
    -0
      database/migrations/2020_08_24_155054_create_documents_table.php
  100. +35
    -0
      database/migrations/2020_08_24_161356_create_bookmarks_table.php

+ 15
- 0
.editorconfig View File

@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2

+ 51
- 0
.env.example View File

@ -0,0 +1,51 @@
APP_NAME=Cyca
APP_ENV=local
APP_KEY=
APP_DEBUG=false
APP_URL=
LARAVEL_ECHO_SERVER_AUTH_HOST="${APP_URL}"
LARAVEL_ECHO_SERVER_REDIS_HOST=redis
FORCE_HTTPS_URLS=true
LOG_CHANNEL=daily
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=cyca
DB_USERNAME=cyca
DB_PASSWORD=
BROADCAST_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=database
SESSION_LIFETIME=120
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=
MAIL_PORT=
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=1
PUSHER_APP_KEY=your-pusher-key
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

+ 5
- 0
.gitattributes View File

@ -0,0 +1,5 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
CHANGELOG.md export-ignore

+ 16
- 0
.gitignore View File

@ -0,0 +1,16 @@
/node_modules
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
laravel-echo-server.lock
ROADMAP.md
CHANGELOG.md
ISSUES.md

+ 13
- 0
.styleci.yml View File

@ -0,0 +1,13 @@
php:
preset: laravel
disabled:
- unused_use
finder:
not-name:
- index.php
- server.php
js:
finder:
not-name:
- webpack.mix.js
css: true

+ 40
- 0
README.md View File

@ -0,0 +1,40 @@
## Installation
You need docker and docker-compose.
After cloning the repository, you might want to check the following file:
```resources/docker/Dockerfile```
It is pre-configured for running Cyca with MariaDB, redis and nginx, but there
are examples to run other database server and additionnal software.
Once satisfied, run:
```docker-compose build```
This can take some time.
Then, create your ```.env``` file and modify it according to your needs:
```cp .env.example .env```
Run the containers:
```docker-compose up```
If there was no errors, install dependencies, create application key and run the
migrations:
```docker exec cyca_app_1 php composer install```
```docker exec cyca_app_1 php artisan key:generate```
```docker exec cyca_app_1 php artisan migrate```
Replace ```cyca_app_1``` with your actual container name if it differs.
You can now stop the containers using Control+C and run them again for good:
```docker-compose up -d```
You should be able to connect to your instance and register a new user. Cyca's
webserver listens to the port 8080.

+ 59
- 0
app/Console/Commands/UpdateDocuments.php View File

@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use App\Jobs\EnqueueDocumentUpdate;
use App\Models\Document;
use Cache;
use Illuminate\Console\Command;
class UpdateDocuments extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'document:update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Enqueue documents that need update';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$oldest = now()->subMinute(config('cyca.maxAge.document'));
$documents = Document::where('checked_at', '<', $oldest)->get();
foreach ($documents as $document) {
$cacheKey = sprintf('queue_document_%d', $document->id);
if (!Cache::has($cacheKey)) {
Cache::forever($cacheKey, now());
EnqueueDocumentUpdate::dispatch($document);
}
}
return 0;
}
}

+ 59
- 0
app/Console/Commands/UpdateFeeds.php View File

@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use App\Jobs\EnqueueFeedUpdate;
use App\Models\Feed;
use Cache;
use Illuminate\Console\Command;
class UpdateFeeds extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'feed:update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Enqueue feeds that need update';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$oldest = now()->subMinute(config('cyca.maxAge.feed'));
$feeds = Feed::where('checked_at', '<', $oldest)->orWhereNull('checked_at')->get();
foreach ($feeds as $feed) {
$cacheKey = sprintf('queue_feed_%d', $feed->id);
if (!Cache::has($cacheKey)) {
Cache::forever($cacheKey, now());
EnqueueFeedUpdate::dispatch($feed);
}
}
return 0;
}
}

+ 42
- 0
app/Console/Kernel.php View File

@ -0,0 +1,42 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('document:update')->everyMinute()->withoutOverlapping();
$schedule->command('feed:update')->everyMinute()->withoutOverlapping();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

+ 55
- 0
app/Exceptions/Handler.php View File

@ -0,0 +1,55 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'password',
'password_confirmation',
];
/**
* Report or log an exception.
*
* @param \Throwable $exception
* @return void
*
* @throws \Exception
*/
public function report(Throwable $exception)
{
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $exception
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Throwable
*/
public function render($request, Throwable $exception)
{
return parent::render($request, $exception);
}
}

+ 10
- 0
app/Exceptions/UserDoesNotExistsException.php View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class UserDoesNotExistsException extends Exception
{
//
}

+ 40
- 0
app/Http/Controllers/Auth/ConfirmPasswordController.php View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\ConfirmsPasswords;
class ConfirmPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Confirm Password Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password confirmations and
| uses a simple trait to include the behavior. You're free to explore
| this trait and override any functions that require customization.
|
*/
use ConfirmsPasswords;
/**
* Where to redirect users when the intended url fails.
*
* @var string
*/
protected $redirectTo = RouteServiceProvider::HOME;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
}

+ 22
- 0
app/Http/Controllers/Auth/ForgotPasswordController.php View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
class ForgotPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
}

+ 40
- 0
app/Http/Controllers/Auth/LoginController.php View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to your home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
*/
protected $redirectTo = RouteServiceProvider::HOME;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest')->except('logout');
}
}

+ 73
- 0
app/Http/Controllers/Auth/RegisterController.php View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use App\Models\User;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
{
/*
|--------------------------------------------------------------------------
| Register Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
/**
* Where to redirect users after registration.
*
* @var string
*/
protected $redirectTo = RouteServiceProvider::HOME;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
}
/**
* Create a new user instance after a valid registration.
*
* @param array $data
* @return \App\User
*/
protected function create(array $data)
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
}

+ 30
- 0
app/Http/Controllers/Auth/ResetPasswordController.php View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\ResetsPasswords;
class ResetPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
/**
* Where to redirect users after resetting their password.
*
* @var string
*/
protected $redirectTo = RouteServiceProvider::HOME;
}

+ 42
- 0
app/Http/Controllers/Auth/VerificationController.php View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\VerifiesEmails;
class VerificationController extends Controller
{
/*
|--------------------------------------------------------------------------
| Email Verification Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling email verification for any
| user that recently registered with the application. Emails may also
| be re-sent if the user didn't receive the original email message.
|
*/
use VerifiesEmails;
/**
* Where to redirect users after verification.
*
* @var string
*/
protected $redirectTo = RouteServiceProvider::HOME;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
$this->middleware('signed')->only('verify');
$this->middleware('throttle:6,1')->only('verify', 'resend');
}
}

+ 13
- 0
app/Http/Controllers/Controller.php View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}

+ 188
- 0
app/Http/Controllers/DocumentController.php View File

@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Documents\StoreRequest;
use App\Models\Bookmark;
use App\Models\Document;
use App\Models\Folder;
use Illuminate\Http\Request;
class DocumentController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$folder = $request->user()->folders()->where('is_selected', true)->first();
return $folder->listDocuments();
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param App\Http\Requests\Documents\StoreRequest $request
* @return \Illuminate\Http\Response
*/
public function store(StoreRequest $request)
{
$validated = $request->validated();
$url = urldecode($validated['url']);
$folder = $request->user()->folders()->findOrFail($validated['folder_id']);
if ($folder->type !== 'folder' && $folder->type !== 'root') {
abort(422);
}
$document = Document::firstOrCreate(['url' => $url]);
$folder->documents()->save($document, [
'initial_url' => $url,
]);
return $folder->listDocuments();
}
/**
* Display the specified resource.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Document $document
* @return \Illuminate\Http\Response
*/
public function show(Request $request, Document $document)
{
$document->findDupplicatesFor($request->user());
$document->loadMissing('feeds')->loadCount(['unreadFeedItems' => function ($query) {
$query->where('is_read', false)->where('user_id', auth()->user()->id);
}]);
return $document;
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Document $document
* @return \Illuminate\Http\Response
*/
public function edit(Document $document)
{
//
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Document $document
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Document $document)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Document $document
* @return \Illuminate\Http\Response
*/
public function destroy(Document $document)
{
//
}
/**
* Move document into specified folder
*
* @param \Illuminate\Http\Request $request
* @param Folder $folder
* @return \Illuminate\Http\Response
*/
public function move(Request $request, Folder $sourceFolder, Folder $targetFolder)
{
if ($sourceFolder->user_id !== $request->user()->id || $targetFolder->user_id !== $request->user()->id) {
abort(404);
}
$documents = $sourceFolder->documents()->whereIn('documents.id', $request->input('documents'))->get();
foreach ($documents as $document) {
$sourceFolder->documents()->updateExistingPivot($document->id, ['folder_id' => $targetFolder->id]);
}
return $sourceFolder->listDocuments();
}
/**
* Remove documents from specified folder
*
* @param \Illuminate\Http\Request $request
* @param Folder $folder
* @return \Illuminate\Http\Response
*/
public function destroyBookmarks(Request $request, Folder $folder)
{
if ($folder->user_id !== $request->user()->id) {
abort(404);
}
$documents = $folder->documents()->whereIn('documents.id', $request->input('documents'))->get();
foreach ($documents as $document) {
$folder->documents()->detach($document);
}
return $folder->listDocuments();
}
/**
* Increment visits for specified document in specified folder
*
* @param \Illuminate\Http\Request $request
* @param Document $document
* @param Folder $folder
* @return \Illuminate\Http\Response
*/
public function visit(Request $request, Document $document, Folder $folder)
{
if ($folder->user_id !== $request->user()->id) {
abort(404);
}
if($folder->type === 'unread_items') {
$folders = $document->findDupplicatesFor($request->user());
foreach($folders as $folder) {
$doc = $folder->documents()->where('documents.id', $document->id)->first();
$folder->documents()->updateExistingPivot($doc->id, ['visits' => $doc->bookmark->visits + 1]);
}
} else {
$document = $folder->documents()->where('documents.id', $document->id)->first();
$folder->documents()->updateExistingPivot($document->id, ['visits' => $document->bookmark->visits + 1]);
}
return $document;
}
}

+ 123
- 0
app/Http/Controllers/FeedController.php View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers;
use App\Models\Feed;
use Illuminate\Http\Request;
use App\Models\IgnoredFeed;
class FeedController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*
* @param \App\Models\Feed $feed
* @return \Illuminate\Http\Response
*/
public function show(Feed $feed)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Feed $feed
* @return \Illuminate\Http\Response
*/
public function edit(Feed $feed)
{
//
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Feed $feed
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Feed $feed)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Feed $feed
* @return \Illuminate\Http\Response
*/
public function destroy(Feed $feed)
{
//
}
/**
* Ignore specified feed
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Feed $feed
* @return \Illuminate\Http\Response
*/
public function ignore(Request $request, Feed $feed)
{
$ignoredFeed = IgnoredFeed::where('user_id', $request->user()->id)->where('feed_id', $feed->id)->first();
if(!$ignoredFeed) {
$ignoredFeed = new IgnoredFeed();
$ignoredFeed->user()->associate($request->user());
$ignoredFeed->feed()->associate($feed);
$ignoredFeed->save();
}
}
/**
* Follow specified feed
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Feed $feed
* @return \Illuminate\Http\Response
*/
public function follow(Request $request, Feed $feed)
{
$ignoredFeed = IgnoredFeed::where('user_id', $request->user()->id)->where('feed_id', $feed->id)->first();
if($ignoredFeed) {
$ignoredFeed->delete();
}
}
}

+ 136
- 0
app/Http/Controllers/FeedItemController.php View File

@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Models\FeedItem;
use App\Models\FeedItemState;
use Illuminate\Http\Request;
class FeedItemController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$feedIds = $request->input('feeds', []);
if (empty($feedIds)) {
return [];
}
$queryBuilder = FeedItem::with('feeds')->whereHas('feeds', function ($query) use ($feedIds) {
$query->whereIn('feeds.id', $feedIds);
});
$queryBuilder->select(['id', 'url', 'title', 'published_at', 'created_at', 'updated_at']);
$folder = $request->user()->folders()->where('is_selected', true)->first();
if ($folder->type === 'unread_items') {
$queryBuilder->whereHas('unreadFeedItems', function ($query) {
$query->where('feed_item_states.user_id', auth()->user()->id)->where('is_read', false);
});
}
return $queryBuilder->withCount(['unreadFeedItems' => function ($query) {
$query->where('is_read', false)->where('user_id', auth()->user()->id);
}])->orderBy('published_at', 'desc')->simplePaginate(15);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*
* @param \App\Models\FeedItem $feedItem
* @return \Illuminate\Http\Response
*/
public function show(FeedItem $feedItem)
{
$feedItem->loadCount(['unreadFeedItems' => function ($query) {
$query->where('is_read', false)->where('user_id', auth()->user()->id);
}]);
return $feedItem;
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\FeedItem $feedItem
* @return \Illuminate\Http\Response
*/
public function edit(FeedItem $feedItem)
{
//
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\FeedItem $feedItem
* @return \Illuminate\Http\Response
*/
public function update(Request $request, FeedItem $feedItem)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\FeedItem $feedItem
* @return \Illuminate\Http\Response
*/
public function destroy(FeedItem $feedItem)
{
//
}
/**
* Mark feed items as read
*
* @param \Illuminate\Http\Request $request
*/
public function markAsRead(Request $request)
{
if ($request->has('folders')) {
$folder = $request->user()->folders()->find($request->input('folders')[0]);
if ($folder && $folder->type === 'unread_items') {
FeedItemState::where('user_id', $request->user()->id)->update(['is_read' => true]);
} else {
FeedItemState::where('user_id', $request->user()->id)->whereIn('folder_id', $request->input('folders'))->update(['is_read' => true]);
}
} else if ($request->has('documents')) {
FeedItemState::where('user_id', $request->user()->id)->whereIn('document_id', $request->input('documents'))->update(['is_read' => true]);
} else if ($request->has('feeds')) {
FeedItemState::where('user_id', $request->user()->id)->whereIn('feed_id', $request->input('feeds'))->update(['is_read' => true]);
} else if ($request->has('feed_items')) {
FeedItemState::where('user_id', $request->user()->id)->whereIn('feed_item_id', $request->input('feed_items'))->update(['is_read' => true]);
}
}
}

+ 85
- 0
app/Http/Controllers/FeedItemStateController.php View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\FeedItemState;
use Illuminate\Http\Request;
class FeedItemStateController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*
* @param \App\Models\FeedItemState $feedItemState
* @return \Illuminate\Http\Response
*/
public function show(FeedItemState $feedItemState)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\FeedItemState $feedItemState
* @return \Illuminate\Http\Response
*/
public function edit(FeedItemState $feedItemState)
{
//
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\FeedItemState $feedItemState
* @return \Illuminate\Http\Response
*/
public function update(Request $request, FeedItemState $feedItemState)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\FeedItemState $feedItemState
* @return \Illuminate\Http\Response
*/
public function destroy(FeedItemState $feedItemState)
{
//
}
}

+ 133
- 0
app/Http/Controllers/FolderController.php View File

@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Folders\StoreRequest;
use App\Http\Requests\Folders\UpdateRequest;
use App\Models\Folder;
use Illuminate\Http\Request;
class FolderController extends Controller
{
public function __construct()
{
$this->authorizeResource(Folder::class, 'folder');
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
return $request->user()->getFlatTree();
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create(Request $request)
{
abort(404);
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Folder\StoreRequest $request
* @return \Illuminate\Http\Response
*/
public function store(StoreRequest $request)
{
$validated = $request->validated();
$folder = Folder::find($validated['parent_id']);
if ($folder->type !== 'folder' && $folder->type !== 'root') {
abort(422);
}
$request->user()->folders()->save(new Folder([
'title' => $validated['title'],
'parent_id' => $validated['parent_id'],
]));
}
/**
* Display the specified resource.
*
* @param \App\Models\Folder $folder
* @return \Illuminate\Http\Response
*/
public function show(Request $request, Folder $folder)
{
$request->user()->folders()->where('id', '<>', $folder->id)->update(['is_selected' => false]);
$folder->is_selected = true;
$folder->save();
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Folder $folder
* @return \Illuminate\Http\Response
*/
public function edit(Request $request, Folder $folder)
{
abort(404);
}
/**
* Update the specified resource in storage.
*
* @param App\Http\Requests\Folder\UpdateRequest $request
* @param \App\Models\Folder $folder
* @return \Illuminate\Http\Response
*/
public function update(UpdateRequest $request, Folder $folder)
{
$validated = $request->validated();
// Don't move a folder to a descendant
$parent = $request->user()->folders()->findOrFail($validated['parent_id']);
while ($parent !== null) {
if ($parent->id === $folder->id) {
abort(422, "Cannot move a folder to one of its descendants");
}
$parent = $parent->parent;
}
$folder->title = $validated['title'];
$folder->parent_id = $validated['parent_id'];
if ($request->has('is_expanded')) {
$folder->is_expanded = $validated['is_expanded'];
}
$folder->save();
return $folder;
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Folder $folder
* @return \Illuminate\Http\Response
*/
public function destroy(Request $request, Folder $folder)
{
$folder->delete();
// We want to ensure at least the root folder is selected
$request->user()->folders()->update(['is_selected' => false]);
$request->user()->folders()->where('type', 'root')->update(['is_selected' => true]);
}
}

+ 244
- 0
app/Http/Controllers/HomeController.php View File

@ -0,0 +1,244 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreAccountRequest;
use Illuminate\Http\Request;
use App\Models\Folder;
use App\Models\Document;
use App\Models\Feed;
use App\Models\IgnoredFeed;
class HomeController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the application dashboard.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
return view('home');
}
/**
* Show user's account page
*/
public function account()
{
return view('account');
}
/**
* Save account's settings
*/
public function accountStore(StoreAccountRequest $request)
{
$validated = $request->validated();
$user = $request->user();
$sendEmailVerificationLink = $user->email !== $validated['email'];
$user->name = $validated['name'];
$user->email = $validated['email'];
if ($sendEmailVerificationLink) {
$user->email_verified_at = null;
}
$user->save();
$user->fresh();
if($sendEmailVerificationLink) {
return redirect()->route('verification.notice');
}
return redirect()->route('account');
}
/**
* Export user's data
*/
public function export(Request $request) {
$root = $request->user()->folders()->ofType('root')->first();
$rootArray = [
'documents' => [],
'folders' => $this->exportTree($root->children()->get())
];
foreach($root->documents()->get() as $document) {
$documentArray = [
'url' => $document->url,
'feeds' => []
];
foreach($document->feeds()->get() as $feed) {
$documentArray['feeds'] = [
'url' => $feed->url,
'is_ignored' => $feed->is_ignored
];
}
$rootArray['documents'][] = $documentArray;
}
return response()->streamDownload(function ()use ($rootArray) {
echo json_encode($rootArray);
}, sprintf('%s - Export.json', $request->user()->name), [
'Content-Type' => 'application/x-json'
]);
}
/**
* Export a single tree branch
*/
protected function exportTree($folders) {
$array = [];
foreach($folders as $folder) {
$folderArray = [
'title' => $folder->title,
'documents' => [],
'folders' => []
];
foreach($folder->documents()->get() as $document) {
$documentArray = [
'url' => $document->url,
'feeds' => []
];
foreach($document->feeds()->get() as $feed) {
$documentArray['feeds'][] = [
'url' => $feed->url,
'is_ignored' => $feed->is_ignored
];
}
$folderArray['documents'][] = $documentArray;
}
$folderArray['folders'] = $this->exportTree(($folder->children()->get()));
$array[] = $folderArray;
}
return $array;
}
/**
* Show the import form
*/
public function showImportForm() {
return view('import');
}
/**
* Import a file
*/
//TODO: Handle different input file types, such as OPML and Netscape bookmarks
public function import(Request $request) {
if(!$request->hasFile('file')) {
abort(422, "A file is required");
}
if (!$request->file('file')->isValid()) {
abort(422, "Invalid file");
}
$contents = file_get_contents((string)$request->file('file'));
$data = [];
try {
$data = json_decode($contents, true);
} catch(\Exception $ex) {
}
$this->importData($data);
return redirect()->route('import');
}
protected function importData($data) {
$user = request()->user();
$root = $user->folders()->ofType('root')->first();
$this->importDocuments($root, $data['documents']);
$this->importFolders($root, $data['folders']);
}
protected function importFolders($folder, $foldersData) {
$user = request()->user();
foreach($foldersData as $folderData) {
$children = $user->folders()->save(new Folder([
'title' => $folderData['title'],
'parent_id' => $folder->id,
]));
$this->importDocuments($children, $folderData['documents']);
$this->importFolders($children, $folderData['folders']);
}
}
protected function importDocuments($folder, $documentsData) {
foreach($documentsData as $docData) {
if(empty($docData['url'])) {
continue;
}
$url = urldecode($docData['url']);
$document = Document::firstOrCreate(['url' => $url]);
$this->importFeeds($document, $docData['feeds']);
$folder->documents()->save($document, [
'initial_url' => $url,
]);
}
}
protected function importFeeds($document, $feedsData) {
$user = request()->user();
$feedsToAttach = $document->feeds()->get()->pluck('id')->all();
foreach($feedsData as $feedData) {
if(empty($feedData['url'])) {
continue;
}
$feedUrl = urldecode($feedData['url']);
$feed = Feed::firstOrCreate(['url' => $feedUrl]);
$feedsToAttach[] = $feed->id;
if($feedData['is_ignored']) {
$ignoredFeed = IgnoredFeed::where('user_id', $user->id)->where('feed_id', $feed->id)->first();
if(!$ignoredFeed) {
$ignoredFeed = new IgnoredFeed();
$ignoredFeed->user()->associate($user);
$ignoredFeed->feed()->associate($feed);
$ignoredFeed->save();
}
}
}
$document->feeds()->sync($feedsToAttach);
}
}

+ 67
- 0
app/Http/Kernel.php View File

@ -0,0 +1,67 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}

+ 21
- 0
app/Http/Middleware/Authenticate.php View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return route('login');
}
}
}

+ 17
- 0
app/Http/Middleware/CheckForMaintenanceMode.php View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode as Middleware;
class CheckForMaintenanceMode extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

+ 17
- 0
app/Http/Middleware/EncryptCookies.php View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*