@ -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 |
@ -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}" |
@ -0,0 +1,5 @@ | |||
* text=auto | |||
*.css linguist-vendored | |||
*.scss linguist-vendored | |||
*.js linguist-vendored | |||
CHANGELOG.md export-ignore |
@ -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 |
@ -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 |
@ -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. |
@ -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; | |||
} | |||
} |
@ -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; | |||
} | |||
} |
@ -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'); | |||
} | |||
} |
@ -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); | |||
} | |||
} |
@ -0,0 +1,10 @@ | |||
<?php | |||
namespace App\Exceptions; | |||
use Exception; | |||
class UserDoesNotExistsException extends Exception | |||
{ | |||
// | |||
} |
@ -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'); | |||
} | |||
} |
@ -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; | |||
} |
@ -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'); | |||
} | |||
} |
@ -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']), | |||
]); | |||
} | |||
} |
@ -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; | |||
} |
@ -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'); | |||
} | |||
} |
@ -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; | |||
} |
@ -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; | |||
} | |||
} |
@ -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(); | |||
} | |||
} | |||
} |
@ -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]); | |||
} | |||
} | |||
} |
@ -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) | |||
{ | |||
// | |||
} | |||
} |
@ -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]); | |||
} | |||
} |
@ -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); | |||
} | |||
} |
@ -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, | |||
]; | |||
} |
@ -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'); | |||
} | |||
} | |||
} |
@ -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 = [ | |||
// | |||
]; | |||
} |
@ -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. | |||
* | |||