Refresh
This commit is contained in:
33
app/Actions/Fortify/CreateNewUser.php
Executable file
33
app/Actions/Fortify/CreateNewUser.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @return \App\Models\User
|
||||
*/
|
||||
public function create(array $input)
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
app/Actions/Fortify/PasswordValidationRules.php
Executable file
18
app/Actions/Fortify/PasswordValidationRules.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Laravel\Fortify\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function passwordRules()
|
||||
{
|
||||
return ['required', 'string', new Password(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
28
app/Actions/Fortify/ResetUserPassword.php
Executable file
28
app/Actions/Fortify/ResetUserPassword.php
Executable file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param mixed $user
|
||||
*/
|
||||
public function reset($user, array $input)
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
33
app/Actions/Fortify/UpdateUserPassword.php
Executable file
33
app/Actions/Fortify/UpdateUserPassword.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param mixed $user
|
||||
*/
|
||||
public function update($user, array $input)
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string'],
|
||||
'password' => $this->passwordRules(),
|
||||
])->after(function ($validator) use ($user, $input) {
|
||||
if (!Hash::check($input['current_password'], $user->password)) {
|
||||
$validator->errors()->add('current_password', __('The provided password does not match your current password.'));
|
||||
}
|
||||
})->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
71
app/Actions/Fortify/UpdateUserProfileInformation.php
Executable file
71
app/Actions/Fortify/UpdateUserProfileInformation.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param mixed $user
|
||||
*/
|
||||
public function update($user, array $input)
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
|
||||
'lang' => [
|
||||
'required',
|
||||
Rule::in(array_keys(config('lang'))),
|
||||
],
|
||||
|
||||
'theme' => [
|
||||
'nullable',
|
||||
Rule::in(['light', 'dark', 'auto']),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'lang' => trim($input['lang']),
|
||||
'theme' => $input['theme'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param mixed $user
|
||||
*/
|
||||
protected function updateVerifiedUser($user, array $input)
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'lang' => trim($input['lang']),
|
||||
'theme' => $input['theme'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
106
app/Analyzers/Analyzer.php
Executable file
106
app/Analyzers/Analyzer.php
Executable file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Analyzers;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Storage;
|
||||
|
||||
abstract class Analyzer
|
||||
{
|
||||
/**
|
||||
* Document being analyzed.
|
||||
*
|
||||
* @var \App\Models\Document
|
||||
*/
|
||||
protected $document;
|
||||
|
||||
/**
|
||||
* File content.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $body;
|
||||
|
||||
/**
|
||||
* Document details (meta data).
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
protected $details;
|
||||
|
||||
/**
|
||||
* Provides temporary access to response to analyzers.
|
||||
*
|
||||
* @var \Illuminate\Http\Client\Response
|
||||
*/
|
||||
protected $response;
|
||||
|
||||
/**
|
||||
* Associate Cyca's document being analized.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setDocument(Document $document)
|
||||
{
|
||||
$this->document = $document;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document's body as fetched by Cyca.
|
||||
*
|
||||
* @param string $body
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setBody($body)
|
||||
{
|
||||
$this->body = $body;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response when fetching document.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setResponse(Response $response)
|
||||
{
|
||||
$this->response = $response;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store details on disk.
|
||||
*/
|
||||
protected function storeDetailsOnDisk()
|
||||
{
|
||||
if (empty($this->details)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storageRoot = $this->document->getStoragePath();
|
||||
$metaFilename = sprintf('%s/meta.json', $storageRoot);
|
||||
|
||||
Storage::put($metaFilename, json_encode($this->details));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store some details in database. This method uses an array to map document
|
||||
* properties to metadata properties.
|
||||
*
|
||||
* @param array $mappings
|
||||
*/
|
||||
protected function applyDetailsToDocument($mappings = [])
|
||||
{
|
||||
foreach ($mappings as $documentKey => $detailsKey) {
|
||||
if (!empty($this->details[$detailsKey])) {
|
||||
$this->document->{$documentKey} = $this->details[$detailsKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Analyzers/ExifAnalyzer.php
Executable file
34
app/Analyzers/ExifAnalyzer.php
Executable file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Analyzers;
|
||||
|
||||
/**
|
||||
* Extract information from a supported image file.
|
||||
*/
|
||||
class ExifAnalyzer extends Analyzer
|
||||
{
|
||||
/**
|
||||
* Analyzes document.
|
||||
*/
|
||||
public function analyze()
|
||||
{
|
||||
if (empty($this->body)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bodyPath = storage_path('app/'.$this->document->getStoragePath().'/body');
|
||||
$this->details = exif_read_data($bodyPath, null, true, true);
|
||||
|
||||
if (empty($this->details)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->document->description = (string) view('partials.details.image')->with([
|
||||
'exif' => $this->details,
|
||||
'url' => asset(str_replace('public', 'storage', $this->document->getStoragePath()).'/body'),
|
||||
]);
|
||||
|
||||
$this->storeDetailsOnDisk();
|
||||
$this->applyDetailsToDocument();
|
||||
}
|
||||
}
|
||||
473
app/Analyzers/HtmlAnalyzer.php
Executable file
473
app/Analyzers/HtmlAnalyzer.php
Executable file
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
|
||||
namespace App\Analyzers;
|
||||
|
||||
use App\Models\Feed;
|
||||
use DomDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
use Elphin\IcoFileLoader\IcoFileService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use SimplePie;
|
||||
use Storage;
|
||||
use Str;
|
||||
|
||||
/**
|
||||
* Extract information from a HTML file.
|
||||
*/
|
||||
class HtmlAnalyzer extends Analyzer
|
||||
{
|
||||
/**
|
||||
* Provides temporary access to DOM document to analyzers.
|
||||
*
|
||||
* @var DOMDocument
|
||||
*/
|
||||
private $domDocument;
|
||||
|
||||
/**
|
||||
* Provides temporary access to <meta> tags to analyzers.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $metaTags = [];
|
||||
|
||||
/**
|
||||
* Provides temporary access to <link> tags to analyzers.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $linkTags = [];
|
||||
|
||||
/**
|
||||
* Analyzes document.
|
||||
*/
|
||||
public function analyze()
|
||||
{
|
||||
if (empty($this->body)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->createDomDocument();
|
||||
$this->findTitle();
|
||||
$this->findMetaTags();
|
||||
$this->findLinkTags();
|
||||
$this->findBestFavicon();
|
||||
$this->discoverFeeds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DOM document from document's body.
|
||||
*/
|
||||
protected function createDomDocument()
|
||||
{
|
||||
$this->body = mb_convert_encoding($this->body, 'HTML-ENTITIES', 'UTF-8');
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$this->domDocument = new DomDocument('1.0', 'UTF-8');
|
||||
|
||||
$this->domDocument->loadHtml($this->body);
|
||||
|
||||
libxml_clear_errors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nodes corresponding to specified XPath query.
|
||||
*
|
||||
* @param string $xpathQuery
|
||||
*
|
||||
* @return DomNodeList
|
||||
*/
|
||||
protected function findNodes($xpathQuery)
|
||||
{
|
||||
$xpath = new DOMXPath($this->domDocument);
|
||||
|
||||
return $xpath->query($xpathQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find first node corresponding to specified XPath query.
|
||||
*
|
||||
* @param string $xpathQuery
|
||||
*
|
||||
* @return DomNode
|
||||
*/
|
||||
protected function findFirstNode($xpathQuery)
|
||||
{
|
||||
$xpath = new DOMXPath($this->domDocument);
|
||||
$nodes = $xpath->query($xpathQuery);
|
||||
|
||||
if ($nodes->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $nodes->item(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover feeds for this document, store them and link them.
|
||||
*/
|
||||
protected function discoverFeeds()
|
||||
{
|
||||
$toSync = $this->document->feeds()->get()->pluck('id')->all();
|
||||
|
||||
$alternateLinks = data_get($this->linkTags, 'Alternate', []);
|
||||
|
||||
// Hard guessing some paths (.rss, ./rss. ./.rss for instance)
|
||||
$potentialNames = ['feed', 'rss', 'atom'];
|
||||
|
||||
foreach ($potentialNames as $potentialName) {
|
||||
$alternateLinks[] = [
|
||||
'type' => 'application/xml',
|
||||
'href' => sprintf('.%s', $potentialName),
|
||||
];
|
||||
|
||||
$alternateLinks[] = [
|
||||
'type' => 'application/xml',
|
||||
'href' => sprintf('./%s', $potentialName),
|
||||
];
|
||||
|
||||
$alternateLinks[] = [
|
||||
'type' => 'application/xml',
|
||||
'href' => sprintf('./.%s', $potentialName),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($alternateLinks as $alternateLink) {
|
||||
if (empty($alternateLink['type']) || !in_array($alternateLink['type'], config('cyca.feedTypes'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = \App\Helpers\Url::makeUrlAbsolute($this->response->effectiveUri(), $alternateLink['href']);
|
||||
} catch (\Exception $ex) {
|
||||
// Malformed URL
|
||||
continue;
|
||||
}
|
||||
|
||||
$client = new SimplePie();
|
||||
|
||||
$client->force_feed(true);
|
||||
$client->set_feed_url($url);
|
||||
$client->set_stupidly_fast(true);
|
||||
$client->enable_cache(false);
|
||||
|
||||
if (!$client->init()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$feed = Feed::firstOrCreate(['url' => $url]);
|
||||
|
||||
if (!in_array($feed->id, $toSync)) {
|
||||
$toSync[] = $feed->id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->document->feeds()->sync($toSync);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place in an array all attributes of a specific DOMElement.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function domElementToArray(DOMElement $node)
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($node->attributes as $attribute) {
|
||||
$key = Str::slug($attribute->localName);
|
||||
$value = \App\Helpers\Cleaner::cleanupString($attribute->nodeValue);
|
||||
|
||||
$data[$key] = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find document's title.
|
||||
*/
|
||||
private function findTitle()
|
||||
{
|
||||
$node = $this->findFirstNode('//head/title');
|
||||
|
||||
if (empty($node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->document->title = \App\Helpers\Cleaner::cleanupString($node->nodeValue, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and parse meta tags.
|
||||
*/
|
||||
private function findMetaTags()
|
||||
{
|
||||
$nodes = $this->findNodes('//head/meta');
|
||||
|
||||
$this->metaTags = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$this->parseMetaTag($node);
|
||||
}
|
||||
|
||||
$this->metaTags = collect($this->metaTags)->sortKeys()->all();
|
||||
|
||||
//TODO: Format description
|
||||
$this->document->description = data_get($this->metaTags, 'meta.Description.content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a meta tag and return a formated array.
|
||||
*/
|
||||
private function parseMetaTag(DOMElement $node)
|
||||
{
|
||||
$data = $this->domElementToArray($node);
|
||||
|
||||
if (empty($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$group = 'nonStandard';
|
||||
$name = null;
|
||||
|
||||
if (!empty($data['charset'])) {
|
||||
$group = 'charset';
|
||||
$name = 'charset';
|
||||
} elseif (!empty($data['name'])) {
|
||||
$group = 'meta';
|
||||
$name = $data['name'];
|
||||
|
||||
$data['originalName'] = $data['name'];
|
||||
|
||||
unset($data['name']);
|
||||
} elseif (!empty($data['property'])) {
|
||||
$group = 'properties';
|
||||
$name = $data['property'];
|
||||
|
||||
$data['originalName'] = $data['property'];
|
||||
|
||||
unset($data['property']);
|
||||
} elseif (!empty($data['http-equiv'])) {
|
||||
$group = 'pragma';
|
||||
$name = $data['http-equiv'];
|
||||
|
||||
$data['originalName'] = $data['http-equiv'];
|
||||
|
||||
unset($data['http-equiv']);
|
||||
}
|
||||
|
||||
$name = Str::studly(str_replace(':', '_', $name));
|
||||
|
||||
if (!empty($name)) {
|
||||
switch ($name) {
|
||||
// Handle specific meta tag formatting here
|
||||
default:
|
||||
$this->metaTags[$group][$name] = $data;
|
||||
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$this->metaTags[$group][] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and parse link tags.
|
||||
*/
|
||||
private function findLinkTags()
|
||||
{
|
||||
$nodes = $this->findNodes('//head/link');
|
||||
|
||||
$this->linkTags = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$this->parseLinkTag($node);
|
||||
}
|
||||
|
||||
$this->linkTags = collect($this->linkTags)->sortKeys()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a link tag and return a formated array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function parseLinkTag(DOMElement $node)
|
||||
{
|
||||
$data = $this->domElementToArray($node);
|
||||
|
||||
if (empty($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$group = 'Others';
|
||||
|
||||
if (!empty($data['rel'])) {
|
||||
$group = Str::studly($data['rel']);
|
||||
}
|
||||
|
||||
$this->linkTags[$group][] = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all link tags marked as being a favicon, then determine which one
|
||||
* is best suited to be the one.
|
||||
*/
|
||||
private function findBestFavicon()
|
||||
{
|
||||
$defaultFaviconUrl = \App\Helpers\Url::makeUrlAbsolute($this->response->effectiveUri(), '/favicon.ico');
|
||||
$potentialIcons = [];
|
||||
|
||||
$links = $this->linkTags;
|
||||
|
||||
foreach ($links as $group => $tags) {
|
||||
foreach ($tags as $tag) {
|
||||
if (!empty($tag['rel']) && in_array($tag['rel'], config('cyca.faviconRels'))) {
|
||||
$potentialIcons[] = $tag['href'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$potentialIcons[] = $defaultFaviconUrl;
|
||||
|
||||
$topWidth = 0;
|
||||
$selectedIcon = null;
|
||||
|
||||
foreach ($potentialIcons as $potentialIcon) {
|
||||
$url = \App\Helpers\Url::makeUrlAbsolute($this->response->effectiveUri(), $potentialIcon);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get($url);
|
||||
} catch (\Exception $ex) {
|
||||
report($ex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$response->ok()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = $response->body();
|
||||
$filePath = sprintf('%s/favicon_%s', $this->document->getStoragePath(), md5($body));
|
||||
|
||||
Storage::put($filePath, $body);
|
||||
|
||||
$mimetype = Storage::mimetype($filePath);
|
||||
|
||||
if (!$this->isValidFavicon($body, $mimetype)) {
|
||||
Storage::delete($filePath);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$width = $this->getImageWidth($body, $mimetype);
|
||||
|
||||
if ($width >= $topWidth) {
|
||||
$topWidth = $width;
|
||||
$selectedIcon = $filePath;
|
||||
} else {
|
||||
Storage::delete($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($selectedIcon)) {
|
||||
$this->document->favicon_path = $selectedIcon;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if favicon has a valid mime type.
|
||||
*
|
||||
* @param string $mimetype
|
||||
* @param mixed $body
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isValidFavicon($body, $mimetype)
|
||||
{
|
||||
if (!in_array($mimetype, config('cyca.faviconTypes'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isValidImage($body, $mimetype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if favicon is a valid image. Mime type is used to adjust tests.
|
||||
*
|
||||
* @param string $mimeType
|
||||
* @param mixed $body
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isValidImage($body, $mimeType)
|
||||
{
|
||||
switch ($mimeType) {
|
||||
case 'image/x-icon':
|
||||
case 'image/vnd.microsoft.icon':
|
||||
$loader = new IcoFileService();
|
||||
|
||||
try {
|
||||
$loader->extractIcon($body, 16, 16);
|
||||
} catch (\Exception $ex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
case 'image/svg':
|
||||
case 'image/svg+xml':
|
||||
$im = new Imagick();
|
||||
|
||||
try {
|
||||
$im->readImageBlob($body);
|
||||
} catch (\Exception $ex) {
|
||||
$im->destroy();
|
||||
|
||||
report($ex);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
default:
|
||||
$res = @imagecreatefromstring($body);
|
||||
|
||||
if (!$res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain width of image.
|
||||
*
|
||||
* @param string $mimeType
|
||||
* @param mixed $body
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function getImageWidth($body, $mimeType)
|
||||
{
|
||||
switch ($mimeType) {
|
||||
case 'image/x-icon':
|
||||
case 'image/vnd.microsoft.icon':
|
||||
return 16;
|
||||
case 'image/svg':
|
||||
case 'image/svg+xml':
|
||||
return 1024;
|
||||
default:
|
||||
$infos = @getimagesizefromstring($body);
|
||||
|
||||
if (!$infos) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $infos[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/Analyzers/PdfAnalyzer.php
Executable file
89
app/Analyzers/PdfAnalyzer.php
Executable file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Analyzers;
|
||||
|
||||
use Smalot\PdfParser\Parser;
|
||||
|
||||
/**
|
||||
* Extract information from a PDF file.
|
||||
*/
|
||||
class PdfAnalyzer extends Analyzer
|
||||
{
|
||||
/**
|
||||
* PDF parser.
|
||||
*
|
||||
* @var \Smalot\PdfParser\Parser
|
||||
*/
|
||||
protected $parser;
|
||||
|
||||
/**
|
||||
* Analyzes document.
|
||||
*/
|
||||
public function analyze()
|
||||
{
|
||||
if (empty($this->body)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->extractDetails();
|
||||
$this->storeDetailsOnDisk();
|
||||
$this->applyDetailsToDocument();
|
||||
|
||||
$this->document->description = (string) view('partials.details.pdf')->with([
|
||||
'details' => $this->details,
|
||||
'url' => asset(str_replace('public', 'storage', $this->document->getStoragePath()).'/body'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store some details in database. This method uses an array to map document
|
||||
* properties to metadata properties.
|
||||
*
|
||||
* @param mixed $mappings
|
||||
*/
|
||||
protected function applyDetailsToDocument($mappings = [])
|
||||
{
|
||||
$mappings = [
|
||||
'title' => 'Title',
|
||||
];
|
||||
|
||||
parent::applyDetailsToDocument($mappings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an instance of PDF parser.
|
||||
*
|
||||
* @return \Smalot\PdfParser\Parser
|
||||
*/
|
||||
private function getParser()
|
||||
{
|
||||
if (!$this->parser) {
|
||||
$this->parser = new Parser();
|
||||
}
|
||||
|
||||
return $this->parser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PDF content.
|
||||
*
|
||||
* @param mixed $content
|
||||
*/
|
||||
private function parseContent($content)
|
||||
{
|
||||
$parser = $this->getParser();
|
||||
|
||||
return $parser->parseContent($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of meta data included in PDF.
|
||||
*/
|
||||
private function extractDetails()
|
||||
{
|
||||
$data = $this->parseContent($this->body);
|
||||
$this->details = $data->getDetails();
|
||||
|
||||
return $this->details;
|
||||
}
|
||||
}
|
||||
74
app/Console/Commands/GenerateRoutes.php
Executable file
74
app/Console/Commands/GenerateRoutes.php
Executable file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Routing\Router;
|
||||
|
||||
class GenerateRoutes extends Command
|
||||
{
|
||||
protected $router;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'route:generate';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generates a json file containing routes made available to frontend';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*/
|
||||
public function __construct(Router $router)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->router = $router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$routes = $this->buildRoutesArray();
|
||||
$json = $routes->toJson();
|
||||
|
||||
file_put_contents(config('routes.target'), $json);
|
||||
|
||||
$this->info(sprintf('Routes successfully generated in %s', config('routes.target')));
|
||||
$this->comment("Don't forget to rebuild assets using npm run dev or npm run prod !");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of whitelisted routes as a Laravel Collection.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
protected function buildRoutesArray()
|
||||
{
|
||||
$routes = [];
|
||||
$whitelist = config('routes.whitelist');
|
||||
|
||||
foreach ($this->router->getRoutes() as $route) {
|
||||
if (!in_array($route->getName(), $whitelist)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$routes[$route->getName()] = $route->uri();
|
||||
}
|
||||
|
||||
return collect($routes);
|
||||
}
|
||||
}
|
||||
51
app/Console/Commands/PurgeReadFeedItems.php
Executable file
51
app/Console/Commands/PurgeReadFeedItems.php
Executable file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\FeedItem;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PurgeReadFeedItems extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'feeditems:purgeread';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Purge old read feed items from the database';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$oldest = now()->subDays(config('cyca.maxOrphanAge.feeditems'));
|
||||
$oldFeedItems = FeedItem::allRead()->olderThan($oldest)->get();
|
||||
|
||||
// We need to do this individually to take advantage of the
|
||||
// FeedItemObserver and automatically delete associated files that may
|
||||
// have been locally stored
|
||||
foreach ($oldFeedItems as $item) {
|
||||
$item->delete();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/UpdateDocuments.php
Executable file
49
app/Console/Commands/UpdateDocuments.php
Executable file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\EnqueueDocumentUpdate;
|
||||
use App\Models\Document;
|
||||
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.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$oldest = now()->subMinute(config('cyca.maxAge.document'));
|
||||
$documents = Document::needingUpdate($oldest)->get();
|
||||
|
||||
foreach ($documents as $document) {
|
||||
EnqueueDocumentUpdate::dispatch($document);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/UpdateFeeds.php
Executable file
49
app/Console/Commands/UpdateFeeds.php
Executable file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\EnqueueFeedUpdate;
|
||||
use App\Models\Feed;
|
||||
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.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$oldest = now()->subMinute(config('cyca.maxAge.feed'));
|
||||
$feeds = Feed::needingUpdate($oldest)->get();
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
EnqueueFeedUpdate::dispatch($feed);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
37
app/Console/Kernel.php
Executable file
37
app/Console/Kernel.php
Executable file
@@ -0,0 +1,37 @@
|
||||
<?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.
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
$schedule->command('document:update')->everyFifteenMinutes()->withoutOverlapping();
|
||||
$schedule->command('feed:update')->everyFifteenMinutes()->withoutOverlapping();
|
||||
$schedule->command('feeditems:purgeread')->daily();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the commands for the application.
|
||||
*/
|
||||
protected function commands()
|
||||
{
|
||||
$this->load(__DIR__.'/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
18
app/Contracts/ImportAdapter.php
Executable file
18
app/Contracts/ImportAdapter.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Interface for data importers.
|
||||
*/
|
||||
interface ImportAdapter
|
||||
{
|
||||
/**
|
||||
* Transforms data from specified request into an importable array. Data
|
||||
* collected from the request could be an uploaded file, credentials for
|
||||
* remote connection, anything the adapter could support.
|
||||
*/
|
||||
public function importFromRequest(Request $request): array;
|
||||
}
|
||||
51
app/Exceptions/Handler.php
Executable file
51
app/Exceptions/Handler.php
Executable file
@@ -0,0 +1,51 @@
|
||||
<?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.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function report(Throwable $exception)
|
||||
{
|
||||
parent::report($exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception into an HTTP response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Throwable
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function render($request, Throwable $exception)
|
||||
{
|
||||
return parent::render($request, $exception);
|
||||
}
|
||||
}
|
||||
9
app/Exceptions/UserDoesNotExistsException.php
Executable file
9
app/Exceptions/UserDoesNotExistsException.php
Executable file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class UserDoesNotExistsException extends Exception
|
||||
{
|
||||
}
|
||||
56
app/Helpers/Cleaner.php
Executable file
56
app/Helpers/Cleaner.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use ForceUTF8\Encoding as UTF8;
|
||||
|
||||
/**
|
||||
* Helper class to cleanup, format, sanitize strings.
|
||||
*/
|
||||
class Cleaner
|
||||
{
|
||||
/**
|
||||
* Ensures string doesn't contain any "undesirable" characters, such as
|
||||
* extra-spaces or line-breaks. This is not a purifying method. Only basic
|
||||
* cleanup is done here.
|
||||
*
|
||||
* @param string $string
|
||||
* @param mixed $stripTags
|
||||
* @param mixed $removeExtraSpaces
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function cleanupString($string, $stripTags = false, $removeExtraSpaces = false)
|
||||
{
|
||||
if (empty($string)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$string = trim($string);
|
||||
$string = UTF8::toUTF8($string, UTF8::ICONV_TRANSLIT);
|
||||
$string = html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$string = str_replace(''', "'", $string);
|
||||
|
||||
if ($removeExtraSpaces) {
|
||||
$string = preg_replace('/[[:space:]]+/', ' ', $string);
|
||||
}
|
||||
|
||||
if ($stripTags) {
|
||||
return strip_tags(trim($string));
|
||||
}
|
||||
|
||||
return self::sanitize($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform some sanitizing actions on specified string.
|
||||
*
|
||||
* @param mixed $string
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function sanitize($string)
|
||||
{
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
36
app/Helpers/Url.php
Executable file
36
app/Helpers/Url.php
Executable file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use League\Uri\Http as UriHttp;
|
||||
use League\Uri\UriResolver;
|
||||
|
||||
/**
|
||||
* Helper to work with urls.
|
||||
*/
|
||||
class Url
|
||||
{
|
||||
/**
|
||||
* Convert specified relative URL to an absolute URL using specified base
|
||||
* URL.
|
||||
*
|
||||
* @param mixed $baseUrl
|
||||
* @param mixed $relativeUrl
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function makeUrlAbsolute($baseUrl, $relativeUrl)
|
||||
{
|
||||
if (\is_string($baseUrl)) {
|
||||
$baseUrl = UriHttp::createFromString($baseUrl);
|
||||
}
|
||||
|
||||
if (\is_string($relativeUrl)) {
|
||||
$relativeUrl = UriHttp::createFromString($relativeUrl);
|
||||
}
|
||||
|
||||
$newUri = UriResolver::resolve($relativeUrl, $baseUrl);
|
||||
|
||||
return (string) $newUri;
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Controller.php
Executable file
24
app/Http/Controllers/Controller.php
Executable file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use DispatchesJobs;
|
||||
use ValidatesRequests;
|
||||
|
||||
public function s3resource(Request $request, $path)
|
||||
{
|
||||
return response(Storage::get($path), 200, [
|
||||
'Content-Type' => Storage::mimeType($path)
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/DocumentController.php
Executable file
136
app/Http/Controllers/DocumentController.php
Executable file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Documents\StoreRequest;
|
||||
use App\Models\Document;
|
||||
use App\Models\Folder;
|
||||
use App\Notifications\UnreadItemsChanged;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Storage;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->authorizeResource(Document::class, 'document');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
$user = $request->user();
|
||||
$url = $validated['url'];
|
||||
$folder = Folder::find($validated['folder_id']);
|
||||
$document = Document::firstOrCreate(['url' => $url]);
|
||||
|
||||
$folder->documents()->save($document, [
|
||||
'initial_url' => $url,
|
||||
]);
|
||||
|
||||
return $folder->listDocuments($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function show(Request $request, Document $document)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$document->findDupplicatesFor($user);
|
||||
|
||||
$document->loadMissing('feeds')->loadCount(['feedItemStates' => function ($query) use ($user) {
|
||||
$query->where('is_read', false)->where('user_id', $user->id);
|
||||
}]);
|
||||
|
||||
if (Storage::exists($document->getStoragePath().'/meta.json')) {
|
||||
$document->meta_data = \json_decode(Storage::get($document->getStoragePath().'/meta.json'));
|
||||
}
|
||||
|
||||
if (Storage::exists($document->getStoragePath().'/response.json')) {
|
||||
$document->response = \json_decode(Storage::get($document->getStoragePath().'/response.json'));
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move document into specified folder.
|
||||
*
|
||||
* @param Folder $folder
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function move(Request $request, Folder $sourceFolder, Folder $targetFolder)
|
||||
{
|
||||
$this->authorize('createBookmarkIn', $targetFolder);
|
||||
$this->authorize('deleteBookmarkFrom', $sourceFolder);
|
||||
|
||||
$bookmarks = $sourceFolder->documents()->whereIn('documents.id', $request->input('documents'))->get();
|
||||
|
||||
foreach ($bookmarks as $bookmark) {
|
||||
$sourceFolder->documents()->updateExistingPivot($bookmark->id, ['folder_id' => $targetFolder->id]);
|
||||
}
|
||||
|
||||
$usersToNotify = $sourceFolder->group->activeUsers->merge($targetFolder->group->activeUsers);
|
||||
|
||||
Notification::send($usersToNotify, new UnreadItemsChanged([
|
||||
'folders' => [
|
||||
$sourceFolder->id,
|
||||
$targetFolder->id,
|
||||
],
|
||||
]));
|
||||
|
||||
return $request->user()->countUnreadItems([
|
||||
'folders' => [
|
||||
$sourceFolder->id,
|
||||
$targetFolder->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove documents from specified folder.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function destroyBookmarks(Request $request, Folder $folder)
|
||||
{
|
||||
$this->authorize('deleteBookmarkFrom', $folder);
|
||||
|
||||
$user = $request->user();
|
||||
$documents = $folder->documents()->whereIn('documents.id', $request->input('documents'))->get();
|
||||
|
||||
foreach ($documents as $document) {
|
||||
$folder->documents()->detach($document);
|
||||
}
|
||||
|
||||
Notification::send($folder->group->activeUsers()->get(), new UnreadItemsChanged(['folders' => [$folder]]));
|
||||
|
||||
return $folder->listDocuments($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment visits for specified document in specified folder.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function visit(Request $request, Document $document)
|
||||
{
|
||||
++$document->visits;
|
||||
$document->save();
|
||||
|
||||
return $this->show($request, $document);
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/FeedController.php
Executable file
43
app/Http/Controllers/FeedController.php
Executable file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\IgnoredFeed;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FeedController extends Controller
|
||||
{
|
||||
/**
|
||||
* Ignore specified 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.
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/Http/Controllers/FeedItemController.php
Executable file
81
app/Http/Controllers/FeedItemController.php
Executable file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\FeedItem;
|
||||
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 [];
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$folder = $user->selectedFolder();
|
||||
|
||||
$queryBuilder = FeedItem::with('feeds:feeds.id,title', 'feeds.documents:documents.id')->inFeeds($feedIds);
|
||||
|
||||
$queryBuilder->select([
|
||||
'feed_items.id',
|
||||
'url',
|
||||
'title',
|
||||
'published_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
|
||||
if ($folder->type === 'unread_items') {
|
||||
$queryBuilder->unreadFor($user);
|
||||
}
|
||||
|
||||
return $queryBuilder->countStates($user)->orderBy('published_at', 'desc')->simplePaginate(15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function show(Request $request, FeedItem $feedItem)
|
||||
{
|
||||
$feedItem->loadCount(['feedItemStates' => function ($query) use ($request) {
|
||||
$query->where('is_read', false)->where('user_id', $request->user()->id);
|
||||
}]);
|
||||
|
||||
$feedItem->loadMissing('feeds');
|
||||
|
||||
return $feedItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark feed items as read.
|
||||
*/
|
||||
public function markAsRead(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($request->has('folders')) {
|
||||
return $user->markFeedItemsReadInFolders($request->input('folders'));
|
||||
}
|
||||
if ($request->has('documents')) {
|
||||
return $user->markFeedItemsReadInDocuments($request->input('documents'));
|
||||
}
|
||||
if ($request->has('feeds')) {
|
||||
return $user->markFeedItemsReadInFeeds($request->input('feeds'));
|
||||
}
|
||||
if ($request->has('feed_items')) {
|
||||
return $user->markFeedItemsRead($request->input('feed_items'));
|
||||
}
|
||||
}
|
||||
}
|
||||
253
app/Http/Controllers/FolderController.php
Executable file
253
app/Http/Controllers/FolderController.php
Executable file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Folders\SetPermissionsRequest;
|
||||
use App\Http\Requests\Folders\StoreRequest;
|
||||
use App\Http\Requests\Folders\UpdateRequest;
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
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)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return $user->getFlatTree();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
$user = $request->user();
|
||||
$parentFolder = Folder::find($validated['parent_id']);
|
||||
$group = Group::find($validated['group_id']);
|
||||
|
||||
$user->createdFolders()->save(new Folder([
|
||||
'title' => $validated['title'],
|
||||
'parent_id' => $parentFolder->id,
|
||||
'group_id' => $group->id,
|
||||
]));
|
||||
|
||||
$user->setFolderExpandedState(true, $parentFolder);
|
||||
|
||||
return $user->getFlatTree($group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function show(Request $request, Folder $folder)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->setSelectedFolder($folder);
|
||||
|
||||
return $folder->listDocuments($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every details of specified folder.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function details(Request $request, Folder $folder)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user->can('view', $folder)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($folder->type === 'unread_items') {
|
||||
$folder->feed_item_states_count = $folder->group->feedItemStatesCount;
|
||||
} else {
|
||||
$folder->user_permissions = $folder->getUserPermissions($user);
|
||||
$folder->default_permissions = $folder->getDefaultPermissions();
|
||||
|
||||
$folder->loadCount(['feedItemStates' => function ($query) use ($user) {
|
||||
$query->where('is_read', false)->where('user_id', $user->id);
|
||||
}]);
|
||||
|
||||
$folder->group->loadCount('activeUsers');
|
||||
}
|
||||
|
||||
return $folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load per-user permissions for specified folder.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function perUserPermissions(Request $request, Folder $folder)
|
||||
{
|
||||
if (!$request->user()->can('setPermission', $folder)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$users = $folder->group->activeUsers()->whereNotIn('users.id', [$request->user()->id])
|
||||
->whereHas('permissions', function ($query) use ($folder) {
|
||||
$query->where('folder_id', $folder->id);
|
||||
})
|
||||
->with(['permissions'=> function ($query) use ($folder) {
|
||||
$query->where('folder_id', $folder->id);
|
||||
}])
|
||||
->select(['users.id', 'users.name', 'users.email'])
|
||||
->get();
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load list of users with no expicit permissions for specified folder.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function usersWithoutPermissions(Request $request, Folder $folder)
|
||||
{
|
||||
if (!$request->user()->can('setPermission', $folder)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$users = $folder->group->activeUsers()->whereNotIn('users.id', [$request->user()->id])
|
||||
->whereDoesntHave('permissions', function ($query) use ($folder) {
|
||||
$query->where('folder_id', $folder->id);
|
||||
})
|
||||
->select(['users.id', 'users.name', 'users.email'])
|
||||
->get();
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param App\Http\Requests\Folder\UpdateRequest $request
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function update(UpdateRequest $request, Folder $folder)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$user = $request->user();
|
||||
|
||||
if ($request->has('is_expanded')) {
|
||||
$user->setFolderExpandedState($validated['is_expanded'], $folder);
|
||||
}
|
||||
|
||||
$folder->title = $validated['title'];
|
||||
$folder->parent_id = $validated['parent_id'];
|
||||
|
||||
if ($folder->isDirty()) {
|
||||
$folder->save();
|
||||
}
|
||||
|
||||
if (!empty($folder->parent_id)) {
|
||||
$user->setFolderExpandedState(true, $folder->parent);
|
||||
}
|
||||
|
||||
return $folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function destroy(Request $request, Folder $folder)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->setSelectedFolder(null, $folder->group);
|
||||
|
||||
$folder->delete();
|
||||
|
||||
return $user->getFlatTree();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle expanded/collapsed a whole folder's branch.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function toggleBranch(Request $request, Folder $folder)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->setFolderExpandedState(!$folder->is_expanded, $folder, $folder->group, true);
|
||||
|
||||
return $user->getFlatTree();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set permissions for specified folder, optionally for specified user.
|
||||
*
|
||||
* @param App\Http\Requests\Folder\SetPermissionsRequest $request
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function setPermission(SetPermissionsRequest $request, Folder $folder)
|
||||
{
|
||||
if (!$request->user()->can('setPermission', $folder)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$ability = !empty($validated['ability']) ? $validated['ability'] : null;
|
||||
$granted = !empty($validated['granted']) ? $validated['granted'] : false;
|
||||
|
||||
if (empty($validated['user_id'])) {
|
||||
$folder->setDefaultPermission($ability, $granted);
|
||||
|
||||
return $this->details($request, $folder);
|
||||
}
|
||||
|
||||
$user = $folder->group->activeUsers()->findOrFail($validated['user_id']);
|
||||
|
||||
$user->setFolderPermissions($folder, $ability, $granted);
|
||||
|
||||
return $this->perUserPermissions($request, $folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove permissions for specified user in specified folder.
|
||||
*
|
||||
* @param App\Http\Requests\Folder\SetPermissionsRequest $request
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function removePermissions(Request $request, Folder $folder, User $user)
|
||||
{
|
||||
if (!$request->user()->can('setPermission', $folder)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user->permissions()->where('folder_id', $folder->id)->delete();
|
||||
|
||||
return $this->perUserPermissions($request, $folder);
|
||||
}
|
||||
}
|
||||
254
app/Http/Controllers/GroupController.php
Executable file
254
app/Http/Controllers/GroupController.php
Executable file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Groups\InviteUserRequest;
|
||||
use App\Http\Requests\Groups\StoreRequest;
|
||||
use App\Http\Requests\Groups\UpdateRequest;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Notifications\AsksToJoinGroup;
|
||||
use App\Notifications\InvitedToJoinGroup;
|
||||
use Illuminate\Http\Request;
|
||||
use Notification;
|
||||
|
||||
class GroupController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->authorizeResource(Group::class, 'group');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$search = $request->input('search');
|
||||
|
||||
$query = Group::visible()
|
||||
->whereNotIn('id', $user->groups->pluck('id'))
|
||||
->with('creator:id,name')
|
||||
->withCount('activeUsers');
|
||||
|
||||
if (!empty($search)) {
|
||||
$query = $query->where('groups.name', 'like', '%'.$search.'%');
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('name')
|
||||
->simplePaginate(25);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource (active groups).
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function indexActive(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return $user->listActiveGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource (my groups).
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function indexMyGroups(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return $user->groups()->withCount('activeUsers', 'pendingUsers')
|
||||
->whereNotIn('status', [
|
||||
Group::$STATUS_REJECTED,
|
||||
Group::$STATUS_LEFT,
|
||||
])->orderBy('position')->orderBy('id')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @param \App\Http\Requests\StoreRequest $request
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function store(StoreRequest $request)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$user = $request->user();
|
||||
$validated['user_id'] = $user->id;
|
||||
$group = Group::create($validated);
|
||||
|
||||
$user->groups()->save($group, [
|
||||
'status' => 'created',
|
||||
]);
|
||||
|
||||
return $user->groups()->withCount('activeUsers')->orderBy('position')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function show(Request $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->setSelectedGroup($group);
|
||||
|
||||
return $user->getFlatTree($group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function update(UpdateRequest $request, Group $group)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$group->name = $validated['name'];
|
||||
$group->description = $validated['description'];
|
||||
$group->invite_only = $validated['invite_only'];
|
||||
$group->auto_accept_users = $validated['auto_accept_users'];
|
||||
|
||||
$group->save();
|
||||
|
||||
return $request->user()->groups()->withCount('activeUsers', 'pendingUsers')->find($group->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function destroy(Request $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$group->delete();
|
||||
|
||||
return $user->groups()->withCount('activeUsers')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update my groups positions.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function updatePositions(Request $request)
|
||||
{
|
||||
if (!$request->has('positions')) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$positions = $request->input('positions');
|
||||
|
||||
if (!is_array($positions)) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
foreach ($positions as $groupId => $position) {
|
||||
if (!is_numeric($groupId) || !is_numeric($position)) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$group = $user->groups()->findOrFail($groupId);
|
||||
|
||||
$user->groups()->updateExistingPivot($group, ['position' => $position]);
|
||||
}
|
||||
|
||||
return $user->groups()->withCount('activeUsers')->orderBy('position')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite user to join specified group.
|
||||
*
|
||||
* @param \App\Requests\Groups\InviteUserRequest $request
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function inviteUser(InviteUserRequest $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user->can('invite', $group)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$invitedUser = User::where('email', $validated['email'])->first();
|
||||
|
||||
if ($invitedUser) {
|
||||
$invitedUser->updateGroupStatus($group, Group::$STATUS_INVITED);
|
||||
}
|
||||
|
||||
Notification::route('mail', $validated['email'])
|
||||
->notify(new InvitedToJoinGroup($request->user(), $group));
|
||||
|
||||
return $request->user()->groups()->withCount('activeUsers', 'pendingUsers')->find($group->id);
|
||||
}
|
||||
|
||||
public function acceptInvitation(Request $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->updateGroupStatus($group, Group::$STATUS_ACCEPTED);
|
||||
|
||||
if ($request->ajax()) {
|
||||
return $user->groups()->withCount('activeUsers', 'pendingUsers')->find($group->id);
|
||||
}
|
||||
|
||||
return redirect()->route('account.groups');
|
||||
}
|
||||
|
||||
public function approveUser(Request $request, Group $group, User $user)
|
||||
{
|
||||
$creator = $request->user();
|
||||
|
||||
if (!$creator->can('approve', $group)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$user->updateGroupStatus($group, Group::$STATUS_ACCEPTED);
|
||||
|
||||
return redirect()->route('account.groups');
|
||||
}
|
||||
|
||||
public function rejectInvitation(Request $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->updateGroupStatus($group, Group::$STATUS_REJECTED);
|
||||
|
||||
return $user->groups()->withCount('activeUsers', 'pendingUsers')->find($group->id);
|
||||
}
|
||||
|
||||
public function leave(Request $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->groups()->detach($group);
|
||||
}
|
||||
|
||||
public function join(Request $request, Group $group)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($group->auto_accept_users) {
|
||||
$user->updateGroupStatus($group, Group::$STATUS_ACCEPTED);
|
||||
} else {
|
||||
$user->updateGroupStatus($group, Group::$STATUS_JOINING);
|
||||
|
||||
Notification::route('mail', $group->creator->email)
|
||||
->notify(new AsksToJoinGroup($request->user(), $group));
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/Http/Controllers/HighlightController.php
Executable file
103
app/Http/Controllers/HighlightController.php
Executable file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreHighlightRequest;
|
||||
use App\Models\Highlight;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HighlightController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function store(StoreHighlightRequest $request)
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$highlight = new Highlight();
|
||||
|
||||
$highlight->user_id = $request->user()->id;
|
||||
$highlight->expression = $data['expression'];
|
||||
$highlight->color = $data['color'];
|
||||
|
||||
$highlight->save();
|
||||
|
||||
return $request->user()->highlights()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*
|
||||
* @param \App\Models\Models\Highlight $highlight
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function update(StoreHighlightRequest $request, Highlight $highlight)
|
||||
{
|
||||
if ($highlight->user_id !== $request->user()->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$highlight->expression = $data['expression'];
|
||||
$highlight->color = $data['color'];
|
||||
|
||||
$highlight->save();
|
||||
|
||||
return $request->user()->highlights()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*
|
||||
* @param \App\Models\Models\Hightlight $hightlight
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function destroy(Request $request, Highlight $highlight)
|
||||
{
|
||||
if ($highlight->user_id !== $request->user()->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$highlight->delete();
|
||||
|
||||
return $request->user()->highlights()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update my groups positions.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function updatePositions(Request $request)
|
||||
{
|
||||
if (!$request->has('positions')) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$positions = $request->input('positions');
|
||||
|
||||
if (!is_array($positions)) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
foreach ($positions as $highlightId => $position) {
|
||||
if (!is_numeric($highlightId) || !is_numeric($position)) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$highlight = $user->highlights()->findOrFail($highlightId);
|
||||
|
||||
$highlight->position = $positions[$highlightId];
|
||||
|
||||
$highlight->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/HomeController.php
Executable file
102
app/Http/Controllers/HomeController.php
Executable file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Exporter;
|
||||
use App\Services\Importer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the application dashboard.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('home');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show application's about page'.
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function about()
|
||||
{
|
||||
return view('account.about');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user's account page.
|
||||
*/
|
||||
public function account()
|
||||
{
|
||||
return view('account.my_account');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user's password update page.
|
||||
*/
|
||||
public function password()
|
||||
{
|
||||
return view('account.password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage user's highlights.
|
||||
*/
|
||||
public function highlights()
|
||||
{
|
||||
return view('account.highlights');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the import form.
|
||||
*/
|
||||
public function showImportForm()
|
||||
{
|
||||
return view('account.import');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a file.
|
||||
*/
|
||||
public function import(Request $request)
|
||||
{
|
||||
(new Importer())->fromRequest($request)->import();
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export user's data.
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$data = (new Exporter())->forUser($request->user())->export();
|
||||
|
||||
return response()->streamDownload(function () use ($data) {
|
||||
echo json_encode($data);
|
||||
}, sprintf('%s - Export.json', $request->user()->name), [
|
||||
'Content-Type' => 'application/x-json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage groups.
|
||||
*/
|
||||
public function groups()
|
||||
{
|
||||
return view('account.groups');
|
||||
}
|
||||
}
|
||||
68
app/Http/Kernel.php
Executable file
68
app/Http/Kernel.php
Executable file
@@ -0,0 +1,68 @@
|
||||
<?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,
|
||||
\App\Http\Middleware\SetLang::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,
|
||||
];
|
||||
}
|
||||
22
app/Http/Middleware/Authenticate.php
Executable file
22
app/Http/Middleware/Authenticate.php
Executable file
@@ -0,0 +1,22 @@
|
||||
<?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 null|string
|
||||
*/
|
||||
protected function redirectTo($request)
|
||||
{
|
||||
if (!$request->expectsJson()) {
|
||||
return route('login');
|
||||
}
|
||||
}
|
||||
}
|
||||
16
app/Http/Middleware/CheckForMaintenanceMode.php
Executable file
16
app/Http/Middleware/CheckForMaintenanceMode.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?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 = [
|
||||
];
|
||||
}
|
||||
16
app/Http/Middleware/EncryptCookies.php
Executable file
16
app/Http/Middleware/EncryptCookies.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?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.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
];
|
||||
}
|
||||
17
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Executable file
17
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Executable file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
|
||||
|
||||
class PreventRequestsDuringMaintenance extends Middleware
|
||||
{
|
||||
/**
|
||||
* The URIs that should be reachable while maintenance mode is enabled.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
];
|
||||
}
|
||||
26
app/Http/Middleware/RedirectIfAuthenticated.php
Executable file
26
app/Http/Middleware/RedirectIfAuthenticated.php
Executable file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RedirectIfAuthenticated
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param null|string $guard
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $guard = null)
|
||||
{
|
||||
if (Auth::guard($guard)->check()) {
|
||||
return redirect(route('home'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
27
app/Http/Middleware/SetLang.php
Executable file
27
app/Http/Middleware/SetLang.php
Executable file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SetLang
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$lang = config('app.locale');
|
||||
|
||||
if ($request->user()) {
|
||||
$lang = $request->user()->lang;
|
||||
}
|
||||
|
||||
app()->setLocale(trim($lang));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
18
app/Http/Middleware/TrimStrings.php
Executable file
18
app/Http/Middleware/TrimStrings.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
|
||||
|
||||
class TrimStrings extends Middleware
|
||||
{
|
||||
/**
|
||||
* The names of the attributes that should not be trimmed.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
}
|
||||
20
app/Http/Middleware/TrustHosts.php
Executable file
20
app/Http/Middleware/TrustHosts.php
Executable file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
/**
|
||||
* Get the host patterns that should be trusted.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function hosts()
|
||||
{
|
||||
return [
|
||||
$this->allSubdomainsOfApplicationUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Middleware/TrustProxies.php
Executable file
23
app/Http/Middleware/TrustProxies.php
Executable file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Fideloper\Proxy\TrustProxies as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TrustProxies extends Middleware
|
||||
{
|
||||
/**
|
||||
* The trusted proxies for this application.
|
||||
*
|
||||
* @var null|array|string
|
||||
*/
|
||||
protected $proxies = '*';
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $headers = Request::HEADER_X_FORWARDED_ALL;
|
||||
}
|
||||
16
app/Http/Middleware/VerifyCsrfToken.php
Executable file
16
app/Http/Middleware/VerifyCsrfToken.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||
|
||||
class VerifyCsrfToken extends Middleware
|
||||
{
|
||||
/**
|
||||
* The URIs that should be excluded from CSRF verification.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
];
|
||||
}
|
||||
56
app/Http/Requests/Documents/StoreRequest.php
Executable file
56
app/Http/Requests/Documents/StoreRequest.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Documents;
|
||||
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
$folder = Folder::find($this->folder_id);
|
||||
|
||||
return $this->user()->can('createBookmarkIn', $folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'url' => [
|
||||
'required',
|
||||
'url',
|
||||
],
|
||||
'group_id' => [
|
||||
'required',
|
||||
Rule::exists(Group::class, 'id'),
|
||||
],
|
||||
'folder_id' => [
|
||||
'required',
|
||||
Rule::exists(Folder::class, 'id'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
$this->merge([
|
||||
'url' => urldecode($this->url),
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/Folders/SetPermissionsRequest.php
Executable file
48
app/Http/Requests/Folders/SetPermissionsRequest.php
Executable file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Folders;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SetPermissionsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return $this->user()->can('setPermission', $this->folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'ability' => [
|
||||
'nullable',
|
||||
Rule::in([
|
||||
'can_create_folder',
|
||||
'can_update_folder',
|
||||
'can_delete_folder',
|
||||
'can_create_document',
|
||||
'can_delete_document',
|
||||
]),
|
||||
],
|
||||
'granted' => [
|
||||
'nullable',
|
||||
'boolean',
|
||||
],
|
||||
'user_id' => [
|
||||
'nullable',
|
||||
Rule::exists('users', 'id'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Folders/StoreRequest.php
Executable file
51
app/Http/Requests/Folders/StoreRequest.php
Executable file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Folders;
|
||||
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
$parentFolder = Folder::find($this->parent_id);
|
||||
|
||||
return $this->user()->can('createIn', $parentFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
$groupId = $this->group_id;
|
||||
|
||||
return [
|
||||
'title' => [
|
||||
'required',
|
||||
'max:255',
|
||||
],
|
||||
// Parent folder ID must exist and in the same group as requested
|
||||
'parent_id' => [
|
||||
'required',
|
||||
Rule::exists(Folder::class, 'id')->where(function ($query) use ($groupId) {
|
||||
$query->where('group_id', '=', $groupId);
|
||||
}),
|
||||
],
|
||||
'group_id' => [
|
||||
'required',
|
||||
Rule::exists(Group::class, 'id'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
107
app/Http/Requests/Folders/UpdateRequest.php
Executable file
107
app/Http/Requests/Folders/UpdateRequest.php
Executable file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Folders;
|
||||
|
||||
use App\Models\Folder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
if (empty($this->parent_id)) {
|
||||
return 'root' === $this->folder->type;
|
||||
}
|
||||
|
||||
$parent = Folder::find($this->parent_id);
|
||||
|
||||
return $this->user()->can('createIn', $parent) && $this->user()->can('update', $this->folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
$groupId = $this->group_id;
|
||||
|
||||
return [
|
||||
'title' => [
|
||||
'required',
|
||||
'max:255',
|
||||
],
|
||||
'parent_id' => [
|
||||
'nullable',
|
||||
Rule::exists('App\Models\Folder', 'id')->where(function ($query) use ($groupId) {
|
||||
$query->where('group_id', '=', $groupId);
|
||||
}),
|
||||
],
|
||||
'group_id' => [
|
||||
'required',
|
||||
Rule::exists('App\Models\Group', 'id'),
|
||||
],
|
||||
'is_expanded' => [
|
||||
'sometimes',
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
if ($this->isMoving()) {
|
||||
if ('folder' !== $this->folder->type) {
|
||||
// Trying to move a "special" folder like root
|
||||
$validator->errors()->add('parent_id', __('You cannot move this folder'));
|
||||
} elseif ($this->targetParentIsDescendant()) {
|
||||
$validator->errors()->add('parent_id', __('You cannot move this folder to a descendant'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if we're moving a folder.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isMoving()
|
||||
{
|
||||
return $this->parent_id !== $this->folder->parent_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if we're trying to move a folder into
|
||||
* one of its descendants.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function targetParentIsDescendant()
|
||||
{
|
||||
$parent = Folder::find($this->parent_id);
|
||||
|
||||
while ($parent) {
|
||||
if ($parent->id === $this->folder->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$parent = $parent->parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Groups/InviteUserRequest.php
Executable file
33
app/Http/Requests/Groups/InviteUserRequest.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InviteUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return $this->user()->can('invite', $this->group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/Groups/StoreRequest.php
Executable file
41
app/Http/Requests/Groups/StoreRequest.php
Executable file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
],
|
||||
'description' => [
|
||||
'nullable',
|
||||
],
|
||||
'invite_only' => [
|
||||
'boolean',
|
||||
],
|
||||
'auto_accept_users' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/Groups/UpdateRequest.php
Executable file
41
app/Http/Requests/Groups/UpdateRequest.php
Executable file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return $this->user()->can('update', $this->group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
],
|
||||
'description' => [
|
||||
'nullable',
|
||||
],
|
||||
'invite_only' => [
|
||||
'boolean',
|
||||
],
|
||||
'auto_accept_users' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/StoreHighlightRequest.php
Executable file
36
app/Http/Requests/StoreHighlightRequest.php
Executable file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreHighlightRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'expression' => [
|
||||
'required',
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
'regex:/^#[0-9a-f]{3,6}$/i',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/ImportAdapters/Cyca.php
Executable file
19
app/ImportAdapters/Cyca.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\ImportAdapters;
|
||||
|
||||
use App\Contracts\ImportAdapter;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class Cyca implements ImportAdapter
|
||||
{
|
||||
/**
|
||||
* Transforms data from specified request into an importable array. Data
|
||||
* collected from the request could be an uploaded file, credentials for
|
||||
* remote connection, anything the adapter could support.
|
||||
*/
|
||||
public function importFromRequest(Request $request): array
|
||||
{
|
||||
return json_decode(file_get_contents($request->file('file')), true);
|
||||
}
|
||||
}
|
||||
65
app/Jobs/EnqueueDocumentUpdate.php
Executable file
65
app/Jobs/EnqueueDocumentUpdate.php
Executable file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EnqueueDocumentUpdate implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Delete the job if its models no longer exist.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $deleteWhenMissingModels = true;
|
||||
|
||||
/**
|
||||
* Document to update.
|
||||
*
|
||||
* @var \App\Models\Document
|
||||
*/
|
||||
protected $document;
|
||||
protected $documentId;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(Document $document)
|
||||
{
|
||||
$this->document = $document;
|
||||
|
||||
if (!empty($this->document->id)) {
|
||||
$this->documentId = $document->id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->document->analyze();
|
||||
$this->document = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unique ID of the job.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function uniqueId()
|
||||
{
|
||||
return $this->document->id;
|
||||
}
|
||||
}
|
||||
66
app/Jobs/EnqueueFeedUpdate.php
Executable file
66
app/Jobs/EnqueueFeedUpdate.php
Executable file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EnqueueFeedUpdate implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Delete the job if its models no longer exist.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $deleteWhenMissingModels = true;
|
||||
|
||||
/**
|
||||
* Feed to update.
|
||||
*
|
||||
* @var \App\Models\Feed
|
||||
*/
|
||||
protected $feed;
|
||||
|
||||
public $feedId = null;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(Feed $feed)
|
||||
{
|
||||
$this->feed = $feed;
|
||||
|
||||
if (!empty($this->feed->id)) {
|
||||
$this->feedId = $feed->id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->feed->analyze();
|
||||
$this->feed = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unique ID of the job.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function uniqueId()
|
||||
{
|
||||
return $this->feedId;
|
||||
}
|
||||
}
|
||||
49
app/Models/Bookmark.php
Executable file
49
app/Models/Bookmark.php
Executable file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
/**
|
||||
* Link between a folder (belonging to a user) and a document.
|
||||
*/
|
||||
class Bookmark extends Pivot
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Name of the table storing bookmarks.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $table = 'bookmarks';
|
||||
|
||||
/**
|
||||
* Indicates if the IDs are auto-incrementing.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $incrementing = true;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Associated document.
|
||||
*/
|
||||
public function document()
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated folder.
|
||||
*/
|
||||
public function folder()
|
||||
{
|
||||
return $this->belongsTo(Folder::class);
|
||||
}
|
||||
}
|
||||
288
app/Models/Document.php
Executable file
288
app/Models/Document.php
Executable file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\Document\AnalysesDocument;
|
||||
use App\Models\Traits\HasUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Document extends Model
|
||||
{
|
||||
use AnalysesDocument;
|
||||
use HasUrl;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'url',
|
||||
];
|
||||
|
||||
/**
|
||||
* Array of folders containing this document. User will be specified in the
|
||||
* findDupplicatesFor method.
|
||||
*/
|
||||
protected $dupplicates = [];
|
||||
|
||||
/**
|
||||
* The accessors to append to the model's array form.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'dupplicates',
|
||||
'favicon',
|
||||
'ascii_url',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be mutated to dates.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = [
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Hash of URL.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $hash;
|
||||
|
||||
/**
|
||||
* Path to storage.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $storagePath;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Attributes |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return document's title, or url if empty.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTitleAttribute()
|
||||
{
|
||||
if (!empty($this->attributes['title'])) {
|
||||
return $this->attributes['title'];
|
||||
}
|
||||
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return array of folders containing a bookmark to this document.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDupplicatesAttribute()
|
||||
{
|
||||
return $this->dupplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return full URL to favicon.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getFaviconAttribute()
|
||||
{
|
||||
if (empty($this->attributes['favicon_path']) || !Storage::exists($this->attributes['favicon_path'])) {
|
||||
if ($this->mimetype) {
|
||||
$filename = str_replace('/', '-', $this->mimetype);
|
||||
$path = sprintf('images/icons/mimetypes/%s.svg', $filename);
|
||||
|
||||
if (file_exists(realpath(public_path($path)))) {
|
||||
return asset($path);
|
||||
}
|
||||
}
|
||||
|
||||
return asset('images/icons/mimetypes/unknown.svg');
|
||||
}
|
||||
|
||||
return Storage::url($this->attributes['favicon_path']);
|
||||
}
|
||||
|
||||
public function getHttpStatusTextAttribute()
|
||||
{
|
||||
if (!empty($this->attributes['http_status_text'])) {
|
||||
return $this->attributes['http_status_text'];
|
||||
}
|
||||
|
||||
if (empty($this->http_status_code)) {
|
||||
if (empty($this->checked_at)) {
|
||||
return __('Cyca did not check this document yet');
|
||||
}
|
||||
|
||||
return __('Cyca could not reach this document URL');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bookmarks referencing this document.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function bookmark()
|
||||
{
|
||||
return $this->belongsToMany(Document::class, 'bookmarks')->using(Bookmark::class)->as('bookmark')->withPivot(['initial_url', 'created_at', 'updated_at', 'visits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders referencing this document.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function folders()
|
||||
{
|
||||
return $this->belongsToMany(Folder::class, 'bookmarks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds referenced by this document.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function feeds()
|
||||
{
|
||||
return $this->belongsToMany(Feed::class, 'document_feeds');
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated feed items states.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function feedItemStates()
|
||||
{
|
||||
return $this->hasMany(FeedItemState::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Scopes |-----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scope a query to only include documents than were updated before specifed
|
||||
* date.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param mixed $date
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeNeedingUpdate($query, $date)
|
||||
{
|
||||
return $query->where('checked_at', '<', $date)->orWhereNull('checked_at');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find dupplicates of this document in specified user's folders.
|
||||
*/
|
||||
public function findDupplicatesFor(User $user)
|
||||
{
|
||||
$ids = $user->documents()->where('document_id', $this->id)->select('folder_id')->pluck('folder_id');
|
||||
|
||||
$folders = Folder::find($ids);
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
$this->dupplicates[] = [
|
||||
'id' => $folder->id,
|
||||
'group_id' => $folder->group->id,
|
||||
'breadcrumbs' => $folder->breadcrumbs,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->dupplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hash for document's URL. Used to build path for storing assets
|
||||
* related to this document. It doesn't need to provide a "secure" hash like
|
||||
* for a password, so we're just going to use md5.
|
||||
*
|
||||
* The purpose of this hash is multiple:
|
||||
*
|
||||
* - Maximum number of folders in each level is 16, and hierarchy is 32
|
||||
* folders deep, so it can be handled by any file system without problem
|
||||
* - As it is based on document's URL and date of creation in Cyca, files
|
||||
* cannot be "stolen" by direct access (Cyca couldn't and shouldn't be used
|
||||
* as a favicon repository used by everyone, for instance)
|
||||
* - It avoids issues with intl domain names or special chars in URLs
|
||||
* - On the other side, it would be easy for Cyca to quickly know where to
|
||||
* store assets for that particular document, and we can store all assets
|
||||
* related to that document in the same folder
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHash()
|
||||
{
|
||||
if (empty($this->hash)) {
|
||||
$this->hash = md5($this->url . $this->created_at);
|
||||
}
|
||||
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return path to root folder for storing this document's assets. This path
|
||||
* can then be used to store and retrieve files using the Storage facade, so
|
||||
* it does not return the full path of a directory rather than the path
|
||||
* related to configured storage disk.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getStoragePath()
|
||||
{
|
||||
if (empty($this->storagePath)) {
|
||||
$hash = $this->getHash();
|
||||
|
||||
$this->storagePath = 'public/documents/' . implode('/', str_split(($hash)));
|
||||
}
|
||||
|
||||
return $this->storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if this document still belongs to any
|
||||
* folder.
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function isOrphan()
|
||||
{
|
||||
return $this->folders()->count() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if this document was orphan for
|
||||
* specified days.
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function wasOrphanFor(int $days)
|
||||
{
|
||||
return !empty($this->checked_at) && $this->checked_at->addDays($days)->lt(now());
|
||||
}
|
||||
}
|
||||
248
app/Models/Feed.php
Executable file
248
app/Models/Feed.php
Executable file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\Feed\AnalysesFeed;
|
||||
use App\Models\Traits\HasUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Feed extends Model
|
||||
{
|
||||
use AnalysesFeed;
|
||||
use HasUrl;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'url',
|
||||
];
|
||||
|
||||
/**
|
||||
* The accessors to append to the model's array form.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'favicon',
|
||||
'is_ignored',
|
||||
'ascii_url',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be mutated to dates.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = [
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Hash of URL.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $hash;
|
||||
|
||||
/**
|
||||
* Path to storage.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $storagePath;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Attributes |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return feed's title, or url if empty.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTitleAttribute()
|
||||
{
|
||||
if (!empty($this->attributes['title'])) {
|
||||
return $this->attributes['title'];
|
||||
}
|
||||
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return full URL to favicon.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getFaviconAttribute()
|
||||
{
|
||||
if (!empty($this->attributes['favicon_path'])) {
|
||||
return Storage::url($this->attributes['favicon_path']);
|
||||
}
|
||||
|
||||
$document = $this->documents()->first();
|
||||
|
||||
if ($document) {
|
||||
return $document->favicon;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if auth'ed user has ignored this feed.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getIsIgnoredAttribute()
|
||||
{
|
||||
if (auth()->user()) {
|
||||
return $this->ignored->firstWhere('user_id', auth()->user()->id) !== null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Documents referenced by this feed.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->belongsToMany(Document::class, 'document_feeds');
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed items referenced by this feed.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function feedItems()
|
||||
{
|
||||
return $this->belongsToMany(FeedItem::class, 'feed_feed_items');
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated unread feed items.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function feedItemStates()
|
||||
{
|
||||
return $this->hasMany(FeedItemState::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Users ignoring this feed.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function ignored()
|
||||
{
|
||||
return $this->hasMany(IgnoredFeed::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Scopes |-----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scope a query to only include feeds than were updated before specifed
|
||||
* date.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param mixed $date
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeNeedingUpdate($query, $date)
|
||||
{
|
||||
return $query->where('checked_at', '<', $date)->orWhereNull('checked_at');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a hash for document's URL. Used to build path for storing assets
|
||||
* related to this document. It doesn't need to provide a "secure" hash like
|
||||
* for a password, so we're just going to use md5.
|
||||
*
|
||||
* The purpose of this hash is multiple:
|
||||
*
|
||||
* - Maximum number of folders in each level is 16, and hierarchy is 32
|
||||
* folders deep, so it can be handled by any file system without problem
|
||||
* - As it is based on document's URL and date of creation in Cyca, files
|
||||
* cannot be "stolen" by direct access (Cyca couldn't and shouldn't be used
|
||||
* as a favicon repository used by everyone, for instance)
|
||||
* - It avoids issues with intl domain names or special chars in URLs
|
||||
* - On the other side, it would be easy for Cyca to quickly know where to
|
||||
* store assets for that particular document, and we can store all assets
|
||||
* related to that document in the same folder
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHash()
|
||||
{
|
||||
if (empty($this->hash)) {
|
||||
$this->hash = md5($this->url . $this->created_at);
|
||||
}
|
||||
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return path to root folder for storing this document's assets. This path
|
||||
* can then be used to store and retrieve files using the Storage facade, so
|
||||
* it does not return the full path of a directory rather than the path
|
||||
* related to configured storage disk.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getStoragePath()
|
||||
{
|
||||
if (empty($this->storagePath)) {
|
||||
$hash = $this->getHash();
|
||||
|
||||
$this->storagePath = 'public/feeds/' . implode('/', str_split(($hash)));
|
||||
}
|
||||
|
||||
return $this->storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if this feed still belongs to any
|
||||
* document.
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function isOrphan()
|
||||
{
|
||||
return $this->documents()->count() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if this feed was orphan for
|
||||
* specified days.
|
||||
*
|
||||
* @return true
|
||||
*/
|
||||
public function wasOrphanFor(int $days)
|
||||
{
|
||||
return !empty($this->checked_at) && $this->checked_at->addDays($days)->lt(now());
|
||||
}
|
||||
}
|
||||
157
app/Models/FeedItem.php
Executable file
157
app/Models/FeedItem.php
Executable file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\HasUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FeedItem extends Model
|
||||
{
|
||||
use HasUrl;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that should be mutated to dates.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = [
|
||||
'published_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The accessors to append to the model's array form.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'ascii_url',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Feeds referenced by this item.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function feeds()
|
||||
{
|
||||
return $this->belongsToMany(Feed::class, 'feed_feed_items');
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated feed item state.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function feedItemStates()
|
||||
{
|
||||
return $this->hasMany(FeedItemState::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Scopes |-----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scope a query to only include feed items read by all users.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeAllRead($query)
|
||||
{
|
||||
return $query->whereDoesntHave('feedItemStates', function ($subQuery) {
|
||||
$subQuery->where('is_read', false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include feed items read by all users.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param mixed $date
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeOlderThan($query, $date)
|
||||
{
|
||||
return $query->where('published_at', '<', $date)
|
||||
->orWhereNull('published_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include feed items associated with specified feeds.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param array $feeds
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeInFeeds($query, $feeds)
|
||||
{
|
||||
return $query->whereHas('feeds', function ($subQuery) use ($feeds) {
|
||||
$subQuery->whereIn('feeds.id', $feeds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include unread feed items for specified user.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeUnreadFor($query, User $user)
|
||||
{
|
||||
return $query->whereHas('feedItemStates', function ($subQuery) use ($user) {
|
||||
$subQuery->where('user_id', $user->id)->where('is_read', false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include unread feed items for specified user.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeCountStates($query, User $user, bool $read = false)
|
||||
{
|
||||
return $query->withCount([
|
||||
'feedItemStates' => function ($subQuery) use ($user, $read) {
|
||||
$subQuery->where('user_id', $user->id)->where('is_read', $read);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return path to root folder for storing this document's assets. This path
|
||||
* can then be used to store and retrieve files using the Storage facade, so
|
||||
* it does not return the full path of a directory rather than the path
|
||||
* related to configured storage disk.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getStoragePath()
|
||||
{
|
||||
if (empty($this->storagePath)) {
|
||||
$hash = $this->hash;
|
||||
|
||||
$this->storagePath = 'public/feeditems/'.implode('/', str_split(($hash)));
|
||||
}
|
||||
|
||||
return $this->storagePath;
|
||||
}
|
||||
}
|
||||
62
app/Models/FeedItemState.php
Executable file
62
app/Models/FeedItemState.php
Executable file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FeedItemState extends Model
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $fillable = [
|
||||
'user_id',
|
||||
'document_id',
|
||||
'feed_id',
|
||||
'feed_item_id',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Associated groups.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\hasManyThrough
|
||||
*/
|
||||
public function groups()
|
||||
{
|
||||
return $this->hasManyThrough(Group::class, Folder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated document.
|
||||
*/
|
||||
public function document()
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Scopes |-----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scope a query to only include unread items.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeUnread($query)
|
||||
{
|
||||
return $query->where('is_read', false);
|
||||
}
|
||||
}
|
||||
403
app/Models/Folder.php
Executable file
403
app/Models/Folder.php
Executable file
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\Folder\BuildsTree;
|
||||
use App\Models\Traits\Folder\CreatesDefaultFolders;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Folder extends Model
|
||||
{
|
||||
use BuildsTree;
|
||||
use CreatesDefaultFolders;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'title',
|
||||
'parent_id',
|
||||
'position',
|
||||
'user_id',
|
||||
'group_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The accessors to append to the model's array form.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'icon',
|
||||
'iconColor',
|
||||
'is_selected',
|
||||
'is_expanded',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Attributes |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return folder's title.
|
||||
*
|
||||
* If it's a special folder, ie not created by user, we will automatically
|
||||
* translate its original title.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTitleAttribute()
|
||||
{
|
||||
switch ($this->type) {
|
||||
// Unspecified type of folder
|
||||
default:
|
||||
return $this->attributes['title'];
|
||||
// Unread items
|
||||
case 'unread_items':
|
||||
return __('Unread items');
|
||||
// Root folder
|
||||
case 'root':
|
||||
return __('Root');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return folder's icon as a fragment identifier for a SVG sprite.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getIconAttribute()
|
||||
{
|
||||
switch ($this->type) {
|
||||
// Unspecified type of folder
|
||||
default:
|
||||
return 'folder';
|
||||
// Unread items
|
||||
case 'unread_items':
|
||||
return 'unread_items';
|
||||
// Root folder
|
||||
case 'root':
|
||||
return 'house';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return icon's color as a CSS class.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getIconColorAttribute()
|
||||
{
|
||||
switch ($this->type) {
|
||||
// Unspecified type of folder
|
||||
default:
|
||||
return 'folder-common';
|
||||
// Unread items
|
||||
case 'unread_items':
|
||||
if ($this->feed_item_states_count > 0) {
|
||||
return 'folder-unread-not-empty';
|
||||
}
|
||||
|
||||
return 'folder-unread';
|
||||
// Root folder
|
||||
case 'root':
|
||||
return 'folder-root';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a formatted path to the folder, using every ascendant's title.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBreadcrumbsAttribute()
|
||||
{
|
||||
$parts = [
|
||||
(string) view('partials.folder', ['folder' => $this]),
|
||||
];
|
||||
|
||||
$parent = $this->parent;
|
||||
|
||||
while ($parent !== null) {
|
||||
$parts[] = (string) view('partials.folder', ['folder' => $parent]);
|
||||
$parent = $parent->parent;
|
||||
}
|
||||
|
||||
$parts[] = (string) view('partials.group', ['group' => $this->group]);
|
||||
|
||||
return implode(' ', array_reverse($parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if this folder is selected by current
|
||||
* user.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getIsSelectedAttribute()
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return auth()->user()->selectedFolder()->id === $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if folder is expanded for specified
|
||||
* user.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getIsExpandedAttribute()
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return auth()->user()->getFolderExpandedState($this);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parent folder.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(Folder::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Children folders.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(Folder::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creator of this folder.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group this folder belongs to.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function group()
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents in this folder.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->belongsToMany(Document::class, 'bookmarks')->using(Bookmark::class)->as('bookmark')->withPivot(['initial_url', 'created_at', 'updated_at', 'visits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated unread feed items.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function feedItemStates()
|
||||
{
|
||||
return $this->hasManyThrough(FeedItemState::class, Bookmark::class, 'folder_id', 'document_id', 'id', 'document_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder's permissions.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function permissions()
|
||||
{
|
||||
return $this->hasMany(Permission::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Scopes |-----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scope a query to only include folders of a given type.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param mixed $type
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeOfType($query, $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static function listDocumentIds($folders, $group)
|
||||
{
|
||||
$unreadItemsFolder = $group->folders()->ofType('unread_items')->first();
|
||||
|
||||
$query = $group->folders()->with('documents:id');
|
||||
|
||||
if (!in_array($unreadItemsFolder->id, $folders)) {
|
||||
$query = $query->whereIn('id', $folders);
|
||||
}
|
||||
|
||||
return $query->get()->pluck('documents')->flatten()->pluck('id')->unique();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of ids of documents present in this folder.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDocumentIds()
|
||||
{
|
||||
return $this->documents()->select('documents.id')->pluck('documents.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of documents built for front-end for specified user.
|
||||
*
|
||||
* @param \App\Models\User $user
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function listDocuments(User $user)
|
||||
{
|
||||
$columns = [
|
||||
'documents.id',
|
||||
'documents.url',
|
||||
'documents.http_status_code',
|
||||
'documents.http_status_text',
|
||||
'documents.mimetype',
|
||||
'documents.title',
|
||||
'documents.favicon_path',
|
||||
'documents.checked_at',
|
||||
];
|
||||
|
||||
if ($this->type === 'unread_items') {
|
||||
$documents = $this->group->documents()->pluck('document_id');
|
||||
|
||||
return Document::select($columns)->with('feeds:feeds.id', 'feeds.ignored', 'bookmark')->withCount(['feedItemStates' => function ($query) use ($user) {
|
||||
$query->where('is_read', false)->where('user_id', $user->id);
|
||||
}])->whereHas('feedItemStates', function ($query) use ($user) {
|
||||
$query->where('is_read', false)->where('user_id', $user->id);
|
||||
})->whereIn('documents.id', $documents)
|
||||
->get();
|
||||
} else {
|
||||
$documentIds = $this->getDocumentIds();
|
||||
|
||||
return Document::select($columns)->with('feeds:feeds.id', 'feeds.ignored', 'bookmark')->withCount(['feedItemStates' => function ($query) use ($user) {
|
||||
$query->where('is_read', false)->where('user_id', $user->id);
|
||||
}])->whereIn('documents.id', $documentIds)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return folder's permissions that applies to any user without explicit
|
||||
* permissions.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDefaultPermissions()
|
||||
{
|
||||
$defaultPermissions = $this->permissions()->whereNull('user_id')->first();
|
||||
|
||||
if (empty($defaultPermissions)) {
|
||||
return [
|
||||
'can_create_folder' => false,
|
||||
'can_update_folder' => false,
|
||||
'can_delete_folder' => false,
|
||||
'can_create_document' => false,
|
||||
'can_delete_document' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'can_create_folder' => $defaultPermissions->can_create_folder,
|
||||
'can_update_folder' => $defaultPermissions->can_update_folder,
|
||||
'can_delete_folder' => $defaultPermissions->can_delete_folder,
|
||||
'can_create_document' => $defaultPermissions->can_create_document,
|
||||
'can_delete_document' => $defaultPermissions->can_delete_document,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user's permission specific to this folder.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getUserPermissions(User $user = null)
|
||||
{
|
||||
if (empty($user)) {
|
||||
if (!auth()->check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
}
|
||||
|
||||
return [
|
||||
'can_change_permissions' => $this->group->user_id === $user->id,
|
||||
'can_create_folder' => $user->can('createIn', $this),
|
||||
'can_update_folder' => $user->can('update', $this),
|
||||
'can_delete_folder' => $user->can('delete', $this),
|
||||
'can_create_document' => $user->can('createBookmarkIn', $this),
|
||||
'can_delete_document' => $user->can('deleteBookmarkFrom', $this),
|
||||
];
|
||||
}
|
||||
|
||||
public function setDefaultPermission($ability = null, $granted = false)
|
||||
{
|
||||
$permissions = $this->permissions()->whereNull('user_id')->first();
|
||||
|
||||
if (!$permissions) {
|
||||
$permissions = new Permission();
|
||||
|
||||
$permissions->folder()->associate($this);
|
||||
|
||||
$permissions->can_create_folder = false;
|
||||
$permissions->can_update_folder = false;
|
||||
$permissions->can_delete_folder = false;
|
||||
$permissions->can_create_document = false;
|
||||
$permissions->can_delete_document = false;
|
||||
}
|
||||
|
||||
if ($ability) {
|
||||
$permissions->{$ability} = $granted;
|
||||
}
|
||||
|
||||
$permissions->save();
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
}
|
||||
265
app/Models/Group.php
Executable file
265
app/Models/Group.php
Executable file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Group extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Constants |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Group is owned by related user.
|
||||
*/
|
||||
public static $STATUS_OWN = 'own';
|
||||
|
||||
/**
|
||||
* Group was created by related user.
|
||||
*/
|
||||
public static $STATUS_CREATED = 'created';
|
||||
|
||||
/**
|
||||
* User has been invited in the group.
|
||||
*/
|
||||
public static $STATUS_INVITED = 'invited';
|
||||
|
||||
/**
|
||||
* User accepted to join the group.
|
||||
*/
|
||||
public static $STATUS_ACCEPTED = 'accepted';
|
||||
|
||||
/**
|
||||
* User declined joining the group.
|
||||
*/
|
||||
public static $STATUS_REJECTED = 'rejected';
|
||||
|
||||
/**
|
||||
* User asked to join a group.
|
||||
*/
|
||||
public static $STATUS_JOINING = 'joining';
|
||||
|
||||
/**
|
||||
* User has left the group.
|
||||
*/
|
||||
public static $STATUS_LEFT = 'left';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'user_id',
|
||||
'invite_only',
|
||||
'auto_accept_users',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'invite_only' => 'boolean',
|
||||
'auto_accept_users' => 'boolean',
|
||||
'feed_item_states_count' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* The accessors to append to the model's array form.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'is_selected',
|
||||
'feed_item_states_count',
|
||||
];
|
||||
|
||||
protected $feedItemStatesCount;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Attributes |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if this group is selected by current
|
||||
* user.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getIsSelectedAttribute()
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return auth()->user()->selectedGroup()->id === $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the number of unread feed items for this group and current user.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getFeedItemStatesCountAttribute()
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->feedItemStatesCount !== null) {
|
||||
return $this->feedItemStatesCount;
|
||||
}
|
||||
|
||||
return $this->getUnreadFeedItemsCountFor(auth()->user());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creator of the group.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated users.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_groups')->withPivot(['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated users.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function activeUsers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_groups')->withPivot(['status'])->whereIn('status', [
|
||||
self::$STATUS_OWN,
|
||||
self::$STATUS_CREATED,
|
||||
self::$STATUS_ACCEPTED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated users in a pending state (either invited or asking to join,
|
||||
* without an answer yet).
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function pendingUsers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_groups')->withPivot(['status'])->whereIn('status', [
|
||||
self::$STATUS_INVITED,
|
||||
self::$STATUS_JOINING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated folders.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function folders()
|
||||
{
|
||||
return $this->hasMany(Folder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated bookmarks.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->hasManyThrough(Bookmark::class, Folder::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Scopes |-----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scope a query to only include user's own group.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeOwn($query)
|
||||
{
|
||||
return $query->where('status', self::$STATUS_OWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include groups the user is active.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
self::$STATUS_OWN,
|
||||
self::$STATUS_CREATED,
|
||||
self::$STATUS_ACCEPTED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include visible groups.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeVisible($query)
|
||||
{
|
||||
return $query->where('invite_only', false);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create default folders for this group.
|
||||
*/
|
||||
public function createDefaultFolders()
|
||||
{
|
||||
Folder::createDefaultFoldersFor($this->creator, $this);
|
||||
}
|
||||
|
||||
public function getUnreadFeedItemsCountFor(User $user)
|
||||
{
|
||||
$documentsIds = $this->documents()->pluck('document_id')->unique();
|
||||
|
||||
$this->feedItemStatesCount = FeedItemState::whereIn('document_id', $documentsIds)
|
||||
->where('is_read', false)->where('user_id', $user->id)->count();
|
||||
|
||||
return $this->feedItemStatesCount;
|
||||
}
|
||||
}
|
||||
16
app/Models/Highlight.php
Executable file
16
app/Models/Highlight.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Highlight extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
34
app/Models/IgnoredFeed.php
Executable file
34
app/Models/IgnoredFeed.php
Executable file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IgnoredFeed extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ignored feed.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function feed()
|
||||
{
|
||||
return $this->belongsTo(Feed::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* User ignoring feed.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
25
app/Models/Observers/BookmarkObserver.php
Executable file
25
app/Models/Observers/BookmarkObserver.php
Executable file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Models\Bookmark;
|
||||
use App\Notifications\UnreadItemsChanged;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class BookmarkObserver
|
||||
{
|
||||
/**
|
||||
* Handle the bookmark "created" event.
|
||||
*/
|
||||
public function created(Bookmark $bookmark)
|
||||
{
|
||||
Notification::send($bookmark->folder->group->activeUsers, new UnreadItemsChanged(['documents' => [$bookmark->document->id]]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the bookmark "deleting" event.
|
||||
*/
|
||||
public function deleting(Bookmark $bookmark)
|
||||
{
|
||||
}
|
||||
}
|
||||
56
app/Models/Observers/DocumentObserver.php
Executable file
56
app/Models/Observers/DocumentObserver.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Jobs\EnqueueDocumentUpdate;
|
||||
use App\Models\Document;
|
||||
use App\Notifications\DocumentUpdated;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentObserver
|
||||
{
|
||||
/**
|
||||
* Handle the document "created" event.
|
||||
*/
|
||||
public function created(Document $document)
|
||||
{
|
||||
EnqueueDocumentUpdate::dispatch($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the document "updated" event.
|
||||
*/
|
||||
public function updated(Document $document)
|
||||
{
|
||||
$usersToNotify = [];
|
||||
|
||||
foreach ($document->folders()->with('user')->get() as $folder) {
|
||||
$usersToNotify[] = $folder->user;
|
||||
}
|
||||
|
||||
Notification::send($usersToNotify, new DocumentUpdated($document));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the document "deleted" event.
|
||||
*/
|
||||
public function deleted(Document $document)
|
||||
{
|
||||
Storage::deleteDirectory($document->getStoragePath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the document "restored" event.
|
||||
*/
|
||||
public function restored(Document $document)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the document "force deleted" event.
|
||||
*/
|
||||
public function forceDeleted(Document $document)
|
||||
{
|
||||
}
|
||||
}
|
||||
17
app/Models/Observers/FeedItemObserver.php
Executable file
17
app/Models/Observers/FeedItemObserver.php
Executable file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Models\FeedItem;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FeedItemObserver
|
||||
{
|
||||
/**
|
||||
* Handle the feed item "deleted" event.
|
||||
*/
|
||||
public function deleted(FeedItem $feedItem)
|
||||
{
|
||||
Storage::deleteDirectory($feedItem->getStoragePath());
|
||||
}
|
||||
}
|
||||
45
app/Models/Observers/FeedObserver.php
Executable file
45
app/Models/Observers/FeedObserver.php
Executable file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Jobs\EnqueueFeedUpdate;
|
||||
use App\Models\Feed;
|
||||
|
||||
class FeedObserver
|
||||
{
|
||||
/**
|
||||
* Handle the feed "created" event.
|
||||
*/
|
||||
public function created(Feed $feed)
|
||||
{
|
||||
EnqueueFeedUpdate::dispatch($feed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the feed "updated" event.
|
||||
*/
|
||||
public function updated(Feed $feed)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the feed "deleted" event.
|
||||
*/
|
||||
public function deleted(Feed $feed)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the feed "restored" event.
|
||||
*/
|
||||
public function restored(Feed $feed)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the feed "force deleted" event.
|
||||
*/
|
||||
public function forceDeleted(Feed $feed)
|
||||
{
|
||||
}
|
||||
}
|
||||
32
app/Models/Observers/FolderObserver.php
Executable file
32
app/Models/Observers/FolderObserver.php
Executable file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Models\Folder;
|
||||
use App\Notifications\UnreadItemsChanged;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class FolderObserver
|
||||
{
|
||||
/**
|
||||
* Handle the folder "created" event.
|
||||
*/
|
||||
public function created(Folder $folder)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the folder "deleting" event.
|
||||
*/
|
||||
public function deleting(Folder $folder)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the folder "deleted" event.
|
||||
*/
|
||||
public function deleted(Folder $folder)
|
||||
{
|
||||
Notification::send($folder->group->activeUsers, new UnreadItemsChanged(['folders' => [$folder]]));
|
||||
}
|
||||
}
|
||||
16
app/Models/Observers/GroupObserver.php
Executable file
16
app/Models/Observers/GroupObserver.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Models\Group;
|
||||
|
||||
class GroupObserver
|
||||
{
|
||||
/**
|
||||
* Handle the group "created" event.
|
||||
*/
|
||||
public function created(Group $group)
|
||||
{
|
||||
$group->createDefaultFolders();
|
||||
}
|
||||
}
|
||||
22
app/Models/Observers/IgnoredFeedObserver.php
Executable file
22
app/Models/Observers/IgnoredFeedObserver.php
Executable file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Models\IgnoredFeed;
|
||||
|
||||
class IgnoredFeedObserver
|
||||
{
|
||||
/**
|
||||
* Handle the ignored feed "created" event.
|
||||
*/
|
||||
public function created(IgnoredFeed $ignoredFeed)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ignored feed "deleting" event.
|
||||
*/
|
||||
public function deleting(IgnoredFeed $ignoredFeed)
|
||||
{
|
||||
}
|
||||
}
|
||||
18
app/Models/Observers/UserObserver.php
Executable file
18
app/Models/Observers/UserObserver.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Observers;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserObserver
|
||||
{
|
||||
/**
|
||||
* Handle the user "created" event.
|
||||
*/
|
||||
public function created(User $user)
|
||||
{
|
||||
$group = $user->createOwnGroup();
|
||||
|
||||
$user->importInitialData($group);
|
||||
}
|
||||
}
|
||||
65
app/Models/Permission.php
Executable file
65
app/Models/Permission.php
Executable file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Permission extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $fillable = [
|
||||
'can_create_folder',
|
||||
'can_update_folder',
|
||||
'can_delete_folder',
|
||||
'can_create_document',
|
||||
'can_delete_document',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $casts = [
|
||||
'can_create_folder' => 'boolean',
|
||||
'can_update_folder' => 'boolean',
|
||||
'can_delete_folder' => 'boolean',
|
||||
'can_create_document' => 'boolean',
|
||||
'can_delete_document' => 'boolean',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Related user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Related folder.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function folder()
|
||||
{
|
||||
return $this->belongsTo(Folder::class);
|
||||
}
|
||||
}
|
||||
78
app/Models/Policies/DocumentPolicy.php
Executable file
78
app/Models/Policies/DocumentPolicy.php
Executable file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Policies;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class DocumentPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function viewAny(User $user)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function view(User $user, Document $document)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function create(User $user)
|
||||
{
|
||||
// Authorization will be checked in target folder
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function update(User $user, Document $document)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete(User $user, Document $document)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function restore(User $user, Document $document)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function forceDelete(User $user, Document $document)
|
||||
{
|
||||
}
|
||||
}
|
||||
211
app/Models/Policies/FolderPolicy.php
Executable file
211
app/Models/Policies/FolderPolicy.php
Executable file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Policies;
|
||||
|
||||
use App\Models\Folder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class FolderPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function viewAny(User $user)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function view(User $user, Folder $folder)
|
||||
{
|
||||
return $this->checkFolderAuthorization($user, $folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function create(User $user)
|
||||
{
|
||||
// We will perform real validation in the createIn method below through
|
||||
// the Folder/StoreRequest FormRequest as we need the folder we're
|
||||
// trying to create a sub-folder to
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function createIn(User $user, Folder $folder)
|
||||
{
|
||||
if ($folder->type === 'unread_items') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkFolderAuthorization($user, $folder, 'can_create_folder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create a bookmark in specified folder.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function createBookmarkIn(User $user, Folder $folder)
|
||||
{
|
||||
if ($folder->type === 'unread_items') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkFolderAuthorization($user, $folder, 'can_create_document');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can remove a bookmark from specified folder.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function deleteBookmarkFrom(User $user, Folder $folder)
|
||||
{
|
||||
if ($folder->type === 'unread_items') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkFolderAuthorization($user, $folder, 'can_delete_document');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function update(User $user, Folder $folder)
|
||||
{
|
||||
return $this->checkFolderAuthorization($user, $folder, 'can_update_folder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete(User $user, Folder $folder)
|
||||
{
|
||||
if ($folder->type !== 'folder') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkFolderAuthorization($user, $folder, 'can_delete_folder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function restore(User $user, Folder $folder)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function forceDelete(User $user, Folder $folder)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update model's permissions.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function setPermission(User $user, Folder $folder)
|
||||
{
|
||||
return $folder->group->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if specified user is the creator of specified folder.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function hasCreatedFolder(User $user, Folder $folder)
|
||||
{
|
||||
return (int) $folder->user_id === (int) $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean value indicating if specified user has created the group
|
||||
* specified folder belongs to.
|
||||
*/
|
||||
private function userCreatedFolderGroup(User $user, Folder $folder)
|
||||
{
|
||||
$group = $this->folderBelongsToActiveUserGroup($user, $folder);
|
||||
|
||||
if (!empty($group)) {
|
||||
return $group->user_id === $user->id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform common authorization tests for specified user and folder.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function checkFolderAuthorization(User $user, Folder $folder, string $ability = null)
|
||||
{
|
||||
if ($this->hasCreatedFolder($user, $folder)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->userCreatedFolderGroup($user, $folder)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = $folder->permissions()->where('user_id', $user->id)->first();
|
||||
|
||||
if (!$permissions) {
|
||||
$defaultPermissions = $folder->permissions()->whereNull('user_id')->first();
|
||||
|
||||
if (empty($defaultPermissions)) {
|
||||
$defaultPermissions = $folder->setDefaultPermission();
|
||||
}
|
||||
|
||||
$permissions = $defaultPermissions;
|
||||
}
|
||||
|
||||
if ($ability) {
|
||||
return $permissions->{$ability};
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if specified folder belongs to a group in which specified user
|
||||
* is active.
|
||||
*
|
||||
* @return \App\Models\Group
|
||||
*/
|
||||
private function folderBelongsToActiveUserGroup(User $user, Folder $folder)
|
||||
{
|
||||
return $user->groups()->active()->find($folder->group_id);
|
||||
}
|
||||
}
|
||||
135
app/Models/Policies/GroupPolicy.php
Executable file
135
app/Models/Policies/GroupPolicy.php
Executable file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Policies;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class GroupPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function viewAny(User $user)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function view(User $user, Group $group)
|
||||
{
|
||||
return $this->checkGroupAuthorization($user, $group, [
|
||||
Group::$STATUS_ACCEPTED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function create(User $user)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function update(User $user, Group $group)
|
||||
{
|
||||
return $group->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can invite someone into specified group.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function invite(User $user, Group $group)
|
||||
{
|
||||
return $this->checkGroupAuthorization($user, $group, [
|
||||
Group::$STATUS_OWN,
|
||||
Group::$STATUS_CREATED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can approve someone to join specified group.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function approve(User $user, Group $group)
|
||||
{
|
||||
return $this->checkGroupAuthorization($user, $group, [
|
||||
Group::$STATUS_OWN,
|
||||
Group::$STATUS_CREATED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete(User $user, Group $group)
|
||||
{
|
||||
return $group->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function restore(User $user, Group $group)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function forceDelete(User $user, Group $group)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform common authorization tests for specified user and group.
|
||||
*
|
||||
* @param mixed $statuses
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function checkGroupAuthorization(User $user, Group $group, $statuses = [])
|
||||
{
|
||||
// Specified user is group's creator
|
||||
if ($group->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$userGroup = $user->groups()->active()->find($group->id);
|
||||
|
||||
if (!$userGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($statuses) && $userGroup->pivot && in_array($userGroup->pivot->status, $statuses)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
226
app/Models/Traits/Document/AnalysesDocument.php
Executable file
226
app/Models/Traits/Document/AnalysesDocument.php
Executable file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\Document;
|
||||
|
||||
use App\Models\Bookmark;
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use SimplePie;
|
||||
use Storage;
|
||||
|
||||
trait AnalysesDocument
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provides temporary access to response to analyzers.
|
||||
*
|
||||
* @var \Illuminate\Http\Client\Response
|
||||
*/
|
||||
protected $response;
|
||||
|
||||
/**
|
||||
* Provides temporary access to document's body to analyzers.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $body;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Begin document analysis.
|
||||
*/
|
||||
public function analyze()
|
||||
{
|
||||
// Don't bother if document isn't bookmarked anymore
|
||||
if ($this->isOrphan() && $this->wasOrphanFor(config('cyca.maxOrphanAge.document'))) {
|
||||
$this->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fetchContent();
|
||||
|
||||
if (empty($this->response)) {
|
||||
$this->checked_at = now();
|
||||
|
||||
$this->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($homepage = $this->isFeed()) {
|
||||
$this->convertToFeed($homepage);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->existingDocumentsMerged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runAnalyzers();
|
||||
|
||||
$this->checked_at = now();
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy content of resource at document's URL in "local" storage.
|
||||
*/
|
||||
protected function fetchContent()
|
||||
{
|
||||
$storageRoot = $this->getStoragePath();
|
||||
$bodyFilename = $storageRoot.'/body';
|
||||
$responseFilename = $storageRoot.'/response.json';
|
||||
$debugFilename = $storageRoot.'/debug';
|
||||
|
||||
Storage::put($debugFilename, null);
|
||||
|
||||
# $debugStream = fopen(storage_path('app/'.$debugFilename), 'w');
|
||||
#
|
||||
try {
|
||||
$this->response = Http::withOptions(array_merge([
|
||||
# 'debug' => env('APP_DEBUG') ? $debugStream : false,
|
||||
], config('http_client')))->timeout(30)->get($this->url);
|
||||
|
||||
$this->body = $this->response->body();
|
||||
} catch (\Exception $ex) {
|
||||
report($ex);
|
||||
# } finally {
|
||||
# fclose($debugStream);
|
||||
}
|
||||
|
||||
if (!$this->response) {
|
||||
return;
|
||||
}
|
||||
|
||||
$psrResponse = $this->response->toPsrResponse();
|
||||
|
||||
$responseData = [
|
||||
'headers' => $this->response->headers(),
|
||||
'protocol_version' => $psrResponse->getProtocolVersion(),
|
||||
'response' => $this->response,
|
||||
];
|
||||
|
||||
Storage::put($responseFilename, json_encode($responseData));
|
||||
|
||||
if ($this->response->ok()) {
|
||||
Storage::put($bodyFilename, $this->body);
|
||||
|
||||
$this->mimetype = Storage::mimetype($bodyFilename);
|
||||
}
|
||||
|
||||
$this->http_status_code = $this->response->status();
|
||||
$this->http_status_text = $this->response->getReasonPhrase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly determine if document is, in fact, a feed, and return
|
||||
* corresponding home page URL.
|
||||
*
|
||||
* @return bool|string Return feed's home page if really a feed, false otherwise
|
||||
*/
|
||||
protected function isFeed()
|
||||
{
|
||||
$client = new SimplePie();
|
||||
|
||||
$client->enable_cache(false);
|
||||
$client->set_raw_data($this->body);
|
||||
|
||||
if ($client->init()) {
|
||||
return urldecode($client->get_permalink());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform this document into a feed, by creating or using an existing
|
||||
* document with provided homepage URL, creating or using an existing feed
|
||||
* with current document's URL, linking both, updating any references to
|
||||
* this document to point to the new document, and finally deleting this
|
||||
* document.
|
||||
*
|
||||
* @param string $homepage
|
||||
*/
|
||||
protected function convertToFeed($homepage)
|
||||
{
|
||||
$document = self::firstOrCreate(['url' => $homepage]);
|
||||
$feed = Feed::firstOrCreate(['url' => $this->url]);
|
||||
|
||||
if (!$document->feeds()->find($feed->id)) {
|
||||
$document->feeds()->attach($feed);
|
||||
}
|
||||
|
||||
Bookmark::where('document_id', $this->id)->update(['document_id' => $document->id, 'initial_url' => $homepage]);
|
||||
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another document having the same real URL. If one is found, we will
|
||||
* update all bookmarks to use the oldest document and delete this one.
|
||||
*
|
||||
* Returns true if documents have been merged.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function existingDocumentsMerged()
|
||||
{
|
||||
$realUrl = urldecode((string) $this->response->effectiveUri());
|
||||
|
||||
if ($realUrl !== $this->url) {
|
||||
$document = self::where('url', $realUrl)->first();
|
||||
|
||||
if ($document) {
|
||||
Bookmark::where('document_id', $this->id)->update(['document_id' => $document->id]);
|
||||
|
||||
$allBookmarks = Bookmark::where('document_id', $this->id)->get()->groupBy('folder_id');
|
||||
|
||||
foreach ($allBookmarks as $folderId => $bookmarks) {
|
||||
if ($bookmarks->count() > 1) {
|
||||
array_shift($bookmarks);
|
||||
|
||||
foreach ($bookmarks as $bookmark) {
|
||||
$bookmark->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->url = $realUrl;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select analyzers for this particular document then run them.
|
||||
*/
|
||||
protected function runAnalyzers()
|
||||
{
|
||||
if (array_key_exists($this->mimetype, config('analyzers'))) {
|
||||
$this->launchAnalyzerFor($this->mimetype);
|
||||
} else {
|
||||
// In doubt, launch HtmlAnalyzer
|
||||
$this->launchAnalyzerFor('text/html');
|
||||
}
|
||||
}
|
||||
|
||||
protected function launchAnalyzerFor($mimetype)
|
||||
{
|
||||
$className = config(sprintf('analyzers.%s', $mimetype));
|
||||
$instance = new $className();
|
||||
|
||||
$instance->setDocument($this)->setBody($this->body)->setResponse($this->response)->analyze();
|
||||
}
|
||||
}
|
||||
222
app/Models/Traits/Feed/AnalysesFeed.php
Executable file
222
app/Models/Traits/Feed/AnalysesFeed.php
Executable file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\Feed;
|
||||
|
||||
use App\Models\FeedItem;
|
||||
use App\Models\FeedItemState;
|
||||
use App\Notifications\UnreadItemsChanged;
|
||||
use DomDocument;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use SimplePie;
|
||||
|
||||
trait AnalysesFeed
|
||||
{
|
||||
// --------------------------------------------------------------------------
|
||||
// ----| Properties |--------------------------------------------------------
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SimplePie client.
|
||||
*
|
||||
* @var SimplePie
|
||||
*/
|
||||
private $client;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// ----| Methods |-----------------------------------------------------------
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Begin feed analysis.
|
||||
*/
|
||||
public function analyze()
|
||||
{
|
||||
// Don't bother if feed isn't attached to any document anymore
|
||||
if ($this->isOrphan() && $this->wasOrphanFor(config('cyca.maxOrphanAge.feed'))) {
|
||||
$this->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->prepareClient();
|
||||
|
||||
if (!$this->client->init()) {
|
||||
$this->error = $this->client->error();
|
||||
$this->checked_at = now();
|
||||
|
||||
$this->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->client->subscribe_url() !== $this->url) {
|
||||
$this->url = $this->client->subscribe_url();
|
||||
}
|
||||
|
||||
$this->title = \App\Helpers\Cleaner::cleanupString($this->client->get_title(), true, true);
|
||||
$this->description = \App\Helpers\Cleaner::cleanupString($this->client->get_description());
|
||||
$this->checked_at = now();
|
||||
|
||||
$this->save();
|
||||
|
||||
$this->createItems($this->client->get_items());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the client.
|
||||
*/
|
||||
protected function prepareClient()
|
||||
{
|
||||
$this->client = new SimplePie();
|
||||
|
||||
Storage::disk('local')->makeDirectory($this->getStoragePath() . '/cache');
|
||||
|
||||
$this->client->force_feed(true);
|
||||
$this->client->set_cache_location(storage_path('app/' . $this->getStoragePath() . '/cache'));
|
||||
$this->client->set_feed_url($this->url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store feed items in database.
|
||||
*
|
||||
* @param array $items
|
||||
*/
|
||||
protected function createItems($items)
|
||||
{
|
||||
$toSync = $this->feedItems()->pluck('feed_items.id')->all();
|
||||
$newItems = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$feedItem = FeedItem::where('hash', $item->get_id(true))->first();
|
||||
|
||||
if (!$feedItem) {
|
||||
$feedItem = new FeedItem();
|
||||
|
||||
$feedItem->hash = $item->get_id(true);
|
||||
$feedItem->title = \App\Helpers\Cleaner::cleanupString($item->get_title(), true, true);
|
||||
$feedItem->url = $item->get_permalink();
|
||||
$feedItem->description = $this->formatText($item->get_description(true));
|
||||
$feedItem->content = $this->formatText($item->get_content(true));
|
||||
$feedItem->published_at = $item->get_gmdate();
|
||||
|
||||
if (empty($feedItem->published_at)) {
|
||||
$feedItem->published_at = now();
|
||||
}
|
||||
|
||||
if ($feedItem->published_at->addDays(config('cyca.maxOrphanAge.feeditems'))->lt(now())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$feedItem->save();
|
||||
|
||||
$data = collect($item->data)->except([
|
||||
'data',
|
||||
'child',
|
||||
]);
|
||||
|
||||
Storage::put($feedItem->getStoragePath() . '/data.json', $data->toJson());
|
||||
}
|
||||
|
||||
if (!in_array($feedItem->id, $toSync)) {
|
||||
$toSync[] = $feedItem->id;
|
||||
}
|
||||
|
||||
$newItems[] = $feedItem;
|
||||
}
|
||||
|
||||
$this->feedItems()->sync($toSync);
|
||||
$this->createUnreadItems($newItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply various transformations to specified text.
|
||||
*
|
||||
* @param string $text
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function formatText($text)
|
||||
{
|
||||
if (empty($text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$text = mb_convert_encoding($text, 'HTML-ENTITIES', 'UTF-8');
|
||||
|
||||
if (empty($text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$domDocument = new DomDocument('1.0', 'UTF-8');
|
||||
|
||||
$domDocument->loadHtml($text, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new DOMXPath($domDocument);
|
||||
|
||||
$anchors = $xpath->query('//a');
|
||||
|
||||
foreach ($anchors as $anchor) {
|
||||
$anchor->setAttribute('rel', 'noopener noreferrer');
|
||||
$anchor->setAttribute('href', urldecode($anchor->getAttribute('href')));
|
||||
}
|
||||
|
||||
$text = $domDocument->saveHTML();
|
||||
|
||||
return \App\Helpers\Cleaner::cleanupString($text);
|
||||
}
|
||||
|
||||
protected function createUnreadItems($feedItems)
|
||||
{
|
||||
$ignoredByUsers = $this->ignored()->pluck('user_id')->all();
|
||||
$documentsChanged = [];
|
||||
$foldersChanged = [];
|
||||
$usersToNotify = [];
|
||||
|
||||
foreach ($this->documents()->get() as $document) {
|
||||
$folders = $document->folders()->get();
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
if (!array_key_exists($folder->id, $foldersChanged)) {
|
||||
$foldersChanged[$folder->id] = $folder;
|
||||
}
|
||||
|
||||
$users = $folder->group->activeUsers()->whereNotIn('users.id', $ignoredByUsers)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if (!array_key_exists($user->id, $usersToNotify)) {
|
||||
$usersToNotify[$user->id] = $user;
|
||||
}
|
||||
|
||||
foreach ($feedItems as $feedItem) {
|
||||
$feedItemStateData = [
|
||||
'document_id' => $document->id,
|
||||
'feed_id' => $this->id,
|
||||
'user_id' => $user->id,
|
||||
'feed_item_id' => $feedItem->id,
|
||||
];
|
||||
|
||||
$feedItemState = FeedItemState::where('user_id', $user->id)
|
||||
->where('feed_item_id', $feedItem->id)
|
||||
->first();
|
||||
|
||||
if (!$feedItemState) {
|
||||
FeedItemState::create($feedItemStateData);
|
||||
|
||||
if (!in_array($document->id, $documentsChanged)) {
|
||||
$documentsChanged[] = $document->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Notification::send($usersToNotify, new UnreadItemsChanged(['folders' => $foldersChanged, 'documents' => $documentsChanged]));
|
||||
}
|
||||
}
|
||||
79
app/Models/Traits/Folder/BuildsTree.php
Executable file
79
app/Models/Traits/Folder/BuildsTree.php
Executable file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\Folder;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Arr;
|
||||
|
||||
/**
|
||||
* Constructs tree representation.
|
||||
*/
|
||||
trait BuildsTree
|
||||
{
|
||||
/**
|
||||
* Return user's folders as a flat tree.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function getFlatTreeFor(User $user, Group $group)
|
||||
{
|
||||
$tree = [];
|
||||
$query = $group->folders()
|
||||
->select([
|
||||
'id',
|
||||
'parent_id',
|
||||
'type',
|
||||
'title',
|
||||
'position',
|
||||
'group_id',
|
||||
])
|
||||
->withCount(['children', 'feedItemStates' => function ($query) use ($user) {
|
||||
$query->where('is_read', false)->where('user_id', $user->id);
|
||||
}])
|
||||
->orderBy('parent_id', 'asc')->orderBy('position', 'asc')
|
||||
->orderBy('title', 'asc');
|
||||
|
||||
$folders = $query->get();
|
||||
|
||||
$roots = $folders->collect()->filter(function ($folder) {
|
||||
return $folder->parent_id === null;
|
||||
});
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if ($root->type === 'unread_items') {
|
||||
$root->feed_item_states_count = $group->feedItemStatesCount;
|
||||
}
|
||||
|
||||
$branch = self::buildBranch($root, $folders, 0);
|
||||
$tree[] = $branch;
|
||||
}
|
||||
|
||||
return collect(Arr::flatten($tree));
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a flat array of sub-folders for specified parent folder.
|
||||
*
|
||||
* @param \App\Models\Folder $folder Parent folder for the branch
|
||||
* @param \Illuminate\Support\Collection $allFolders All folders associated to the same user as the parent folder
|
||||
* @param int $depth Current depth
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function buildBranch(self $folder, $allFolders, $depth)
|
||||
{
|
||||
$folder->depth = $depth;
|
||||
$branch = [];
|
||||
|
||||
$branch[] = $folder;
|
||||
|
||||
$subFolders = $allFolders->collect()->where('parent_id', $folder->id);
|
||||
|
||||
foreach ($subFolders as $subFolder) {
|
||||
$branch[] = self::buildBranch($subFolder, $allFolders, $depth + 1);
|
||||
}
|
||||
|
||||
return $branch;
|
||||
}
|
||||
}
|
||||
57
app/Models/Traits/Folder/CreatesDefaultFolders.php
Executable file
57
app/Models/Traits/Folder/CreatesDefaultFolders.php
Executable file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\Folder;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
|
||||
trait CreatesDefaultFolders
|
||||
{
|
||||
// --------------------------------------------------------------------------
|
||||
// ----| Constants |---------------------------------------------------------
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Position of the unread items folder in the folders hierarchy.
|
||||
*/
|
||||
private static $POSITION_UNREAD_ITEMS = 0;
|
||||
|
||||
/**
|
||||
* Position of the root folder in the folders hierarchy.
|
||||
*/
|
||||
private static $POSITION_ROOT = 1;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// ----| Methods |-----------------------------------------------------------
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create default folders for specified group. This method should be called
|
||||
* only once when group is created.
|
||||
*
|
||||
* @param \App\Models\User $user User creating the folders
|
||||
*
|
||||
* @throws \App\Exceptions\UserDoesNotExistsException
|
||||
*/
|
||||
public static function createDefaultFoldersFor(User $user, Group $group)
|
||||
{
|
||||
$group->folders()->saveMany([
|
||||
new self([
|
||||
'type' => 'unread_items',
|
||||
'title' => 'Unread items',
|
||||
'position' => self::$POSITION_UNREAD_ITEMS,
|
||||
'user_id' => $user->id,
|
||||
]),
|
||||
new self([
|
||||
'type' => 'root',
|
||||
'title' => 'Root',
|
||||
'position' => self::$POSITION_ROOT,
|
||||
'user_id' => $user->id,
|
||||
]),
|
||||
]);
|
||||
|
||||
session([
|
||||
sprintf('selectedFolder.%d', $group->id) => $group->folders()->ofType('root')->first()->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
app/Models/Traits/HasUrl.php
Executable file
50
app/Models/Traits/HasUrl.php
Executable file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
trait HasUrl
|
||||
{
|
||||
/**
|
||||
* Return url in its idn form. Adds HTML markup to "syntax highlight" url
|
||||
* elements.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getAsciiUrlAttribute()
|
||||
{
|
||||
if (empty($this->attributes['url'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
$url = \urldecode($this->attributes['url']);
|
||||
|
||||
$host = \parse_url($url, PHP_URL_HOST);
|
||||
$ascii = \idn_to_ascii($host);
|
||||
$idnUrl = str_replace($host, $ascii, $url);
|
||||
$finalUrl = '';
|
||||
|
||||
foreach (preg_split('//u', $idnUrl, null, PREG_SPLIT_NO_EMPTY) as $char) {
|
||||
if (mb_strlen($char) != strlen($char)) {
|
||||
$class = 'suspicious';
|
||||
} elseif (preg_match('#[A-Z]#', $char)) {
|
||||
$class = 'capital';
|
||||
} elseif (preg_match('#[a-z]#', $char)) {
|
||||
$class = 'letter';
|
||||
} elseif (preg_match('#[0-9]#', $char)) {
|
||||
$class = 'number';
|
||||
} elseif (preg_match('#([:/.?$\#_=])#', $char)) {
|
||||
$class = 'operator';
|
||||
} elseif (empty($char)) {
|
||||
$class = 'empty';
|
||||
} else {
|
||||
$class = 'other';
|
||||
}
|
||||
|
||||
$finalUrl .= sprintf('<span class="%s">%s</span>', $class, $char);
|
||||
}
|
||||
|
||||
return $finalUrl;
|
||||
}
|
||||
}
|
||||
171
app/Models/Traits/User/HasFeeds.php
Executable file
171
app/Models/Traits/User/HasFeeds.php
Executable file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\User;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\FeedItemState;
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
|
||||
trait HasFeeds
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Associated feed item state.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function feedItemStates()
|
||||
{
|
||||
return $this->hasMany(FeedItemState::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mark feed items as read in specified folders, as an array of folder ids.
|
||||
*
|
||||
* @param array $folders
|
||||
*/
|
||||
public function markFeedItemsReadInFolders($folders, Group $group = null)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
$unreadItemsFolder = $group->folders()->ofType('unread_items')->first();
|
||||
|
||||
$query = $group->folders()->with('documents:documents.id');
|
||||
|
||||
if (!in_array($unreadItemsFolder->id, $folders)) {
|
||||
$query = $query->whereIn('folders.id', $folders);
|
||||
}
|
||||
|
||||
$folders = $query->get();
|
||||
$documentIds = $query->get()->pluck('documents')->flatten()->pluck('id')->unique();
|
||||
|
||||
$query = $this->feedItemStates()->unread()->whereIn('document_id', $documentIds);
|
||||
$feedItemIds = $query->pluck('feed_item_id')->unique();
|
||||
|
||||
$query->update(['is_read' => true]);
|
||||
|
||||
return $this->countUnreadItems([
|
||||
'folders' => $folders,
|
||||
'documents' => $documentIds,
|
||||
'updated_feed_items' => $feedItemIds,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark feed items as read in specified documents, as an array of document
|
||||
* ids.
|
||||
*
|
||||
* @param array $documents
|
||||
*/
|
||||
public function markFeedItemsReadInDocuments($documents)
|
||||
{
|
||||
$query = $this->feedItemStates()->unread()->whereIn('document_id', $documents);
|
||||
$feedItemIds = $query->pluck('feed_item_id')->unique();
|
||||
|
||||
$query->update(['is_read' => true]);
|
||||
|
||||
return $this->countUnreadItems([
|
||||
'documents' => $documents,
|
||||
'updated_feed_items' => $feedItemIds,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark feed items as read in specified feeds, as an array of feed ids.
|
||||
*
|
||||
* @param array $feeds
|
||||
*/
|
||||
public function markFeedItemsReadInFeeds($feeds)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark specified feed items as read, as an array of feed item ids.
|
||||
*
|
||||
* @param array $feedItems
|
||||
*/
|
||||
public function markFeedItemsRead($feedItems)
|
||||
{
|
||||
$query = $this->feedItemStates()->unread()->whereIn('feed_item_id', $feedItems);
|
||||
$feedItemIds = $query->pluck('feed_item_id')->unique();
|
||||
$documentIds = $query->pluck('document_id')->unique();
|
||||
|
||||
$query->update(['is_read' => true]);
|
||||
|
||||
return $this->countUnreadItems([
|
||||
'documents' => $documentIds,
|
||||
'updated_feed_items' => $feedItemIds,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate unread items counts for current user. The $for array allows to
|
||||
* be more specific by specifying ids for feed_items, documents or folder
|
||||
* to re-count for in particular.
|
||||
*
|
||||
* This method returns an array containing the id of each document and
|
||||
* folder along with corresponding unread items count, as well as a total
|
||||
* of unread items count for each group and folders of type "unread_items".
|
||||
*
|
||||
* @param array $for
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function countUnreadItems($for)
|
||||
{
|
||||
$data = [];
|
||||
|
||||
if (empty($for['documents'])) {
|
||||
if (!empty($for['folders'])) {
|
||||
$for['documents'] = Folder::listDocumentIds(collect($for['folders'])->pluck('id')->all(), $this->selectedGroup());
|
||||
}
|
||||
}
|
||||
|
||||
$countPerDocument = $this->feedItemStates()->unread()->whereIn('document_id', $for['documents'])->get()->countBy('document_id')->all();
|
||||
$countPerGroup = [];
|
||||
|
||||
if (!empty($for['documents'])) {
|
||||
foreach ($for['documents'] as $id) {
|
||||
if (!array_key_exists($id, $countPerDocument)) {
|
||||
$countPerDocument[$id] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($for['folders'])) {
|
||||
$folderIds = Document::with('folders')->find($for['documents'])->pluck('folders')->flatten()->pluck('id');
|
||||
|
||||
$for['folders'] = Folder::find($folderIds);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($for['folders'] as $folder) {
|
||||
$countPerFolder[$folder->id] = $this->feedItemStates()->unread()->whereIn('document_id', $folder->getDocumentIds())->count();
|
||||
}
|
||||
|
||||
foreach ($this->groups as $group) {
|
||||
$totalUnreadItems = $group->getUnreadFeedItemsCountFor($this);
|
||||
$unreadItemsFolderId = $group->folders()->ofType('unread_items')->first()->id;
|
||||
|
||||
$countPerFolder[$unreadItemsFolderId] = $totalUnreadItems;
|
||||
$countPerGroup[$group->id] = $totalUnreadItems;
|
||||
}
|
||||
|
||||
return [
|
||||
'documents' => $countPerDocument,
|
||||
'folders' => $countPerFolder,
|
||||
'groups' => $countPerGroup,
|
||||
'updated_feed_items' => !empty($for['updated_feed_items']) ? $for['updated_feed_items'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
244
app/Models/Traits/User/HasFolders.php
Executable file
244
app/Models/Traits/User/HasFolders.php
Executable file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\User;
|
||||
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
use App\Models\Permission;
|
||||
|
||||
trait HasFolders
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Currently selected folder in each group.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $selectedFolders = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Folders owned (created) by this user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function createdFolders()
|
||||
{
|
||||
return $this->hasMany(Folder::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return user's folders as a flat tree.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getFlatTree(Group $group = null)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
return Folder::getFlatTreeFor($this, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current user's selected folder in specified group.
|
||||
*
|
||||
* @return null|\App\Models\Group
|
||||
*/
|
||||
public function selectedFolder(Group $group = null)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
if (empty($this->selectedFolders[$group->id])) {
|
||||
$this->selectedFolders[$group->id] = $this->fetchSelectedFolder($group);
|
||||
}
|
||||
|
||||
return $this->selectedFolders[$group->id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember user's selected folder in specified (or current) group.
|
||||
*
|
||||
* @param \App\Models\Folder $folder
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function setSelectedFolder(Folder $folder = null, Group $group = null)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
$this->selectedFolders[$group->id] = $folder;
|
||||
|
||||
$this->storeSelectedFolder($group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return specified folder's expanded/collapsed state in specified group.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getFolderExpandedState(Folder $folder = null, Group $group = null)
|
||||
{
|
||||
if (empty($folder)) {
|
||||
$folder = $this->selectedFolder($group);
|
||||
}
|
||||
|
||||
$key = $this->folderExpandedStoreKey($folder, $group);
|
||||
|
||||
return (bool) cache($key, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specified folder's expanded/collapsed state in specified group.
|
||||
*
|
||||
* @param bool $expanded
|
||||
* @param bool $recursive Apply new state recursively
|
||||
*/
|
||||
public function setFolderExpandedState($expanded, Folder $folder = null, Group $group = null, $recursive = false)
|
||||
{
|
||||
$key = $this->folderExpandedStoreKey($folder, $group);
|
||||
|
||||
cache()->forever($key, (bool) $expanded);
|
||||
|
||||
if ($recursive) {
|
||||
foreach ($folder->children as $subFolder) {
|
||||
$this->setFolderExpandedState($expanded, $subFolder, $group, $recursive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure specified folder's ancestor are all expanded, so the folder is
|
||||
* visible.
|
||||
*
|
||||
* @param \App\Models\Folder $folder
|
||||
*/
|
||||
public function ensureAncestorsAreExpanded(Folder $folder = null)
|
||||
{
|
||||
if (empty($folder)) {
|
||||
$folder = $this->selectedFolder();
|
||||
}
|
||||
|
||||
while ($folder = $folder->parent) {
|
||||
$this->setFolderExpandedState(true, $folder);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define this user permission for specified folder and ability.
|
||||
*
|
||||
* @param null|mixed $ability
|
||||
* @param mixed $grant
|
||||
*/
|
||||
public function setFolderPermissions(Folder $folder, $ability = null, $grant = false)
|
||||
{
|
||||
$permissions = $this->permissions()->where('folder_id', $folder->id)->first();
|
||||
|
||||
if (!$permissions) {
|
||||
$permissions = new Permission();
|
||||
|
||||
$permissions->user()->associate($this);
|
||||
$permissions->folder()->associate($folder);
|
||||
}
|
||||
|
||||
$defaultPermissions = $folder->getDefaultPermissions();
|
||||
|
||||
if ($ability !== null) {
|
||||
$permissions->{$ability} = $grant;
|
||||
} else {
|
||||
foreach ($defaultPermissions as $defaultAbility => $defaultGrant) {
|
||||
$permissions->{$defaultAbility} = $defaultGrant;
|
||||
}
|
||||
}
|
||||
|
||||
$permissions->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key to access reminded selected folder for this user and
|
||||
* specified group.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function selectedFolderStoreKey(Group $group)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
return sprintf('selectedFolder.%d.%d', $this->id, $group->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return stored user's selected folder in specified group.
|
||||
*
|
||||
* @return \App\Models\Folder
|
||||
*/
|
||||
protected function fetchSelectedFolder(Group $group)
|
||||
{
|
||||
$key = $this->selectedFolderStoreKey($group);
|
||||
|
||||
if (cache()->has($key)) {
|
||||
$folder = $group->folders()->find(cache($key));
|
||||
|
||||
if (!empty($folder)) {
|
||||
return $folder;
|
||||
}
|
||||
}
|
||||
|
||||
return $group->folders()->ofType('root')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user's selected folder in specified group.
|
||||
*/
|
||||
protected function storeSelectedFolder(Group $group)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
$key = $this->selectedFolderStoreKey($group);
|
||||
$folder = $this->selectedFolders[$group->id];
|
||||
|
||||
if (!empty($folder)) {
|
||||
cache()->forever($key, $folder->id);
|
||||
} else {
|
||||
cache()->forget($key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key to get specified folder's expanded/collased state in
|
||||
* specified group.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function folderExpandedStoreKey(Folder $folder = null, Group $group = null)
|
||||
{
|
||||
if (empty($group)) {
|
||||
$group = $this->selectedGroup();
|
||||
}
|
||||
|
||||
if (empty($folder)) {
|
||||
$folder = $this->selectedFolder($group);
|
||||
}
|
||||
|
||||
return sprintf('folderExpandedState.%d.%d.%d', $this->id, $group->id, $folder->id);
|
||||
}
|
||||
}
|
||||
177
app/Models/Traits/User/HasGroups.php
Executable file
177
app/Models/Traits/User/HasGroups.php
Executable file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits\User;
|
||||
|
||||
use App\Models\Group;
|
||||
|
||||
trait HasGroups
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Currently selected group.
|
||||
*
|
||||
* @var \App\Models\Group
|
||||
*/
|
||||
protected $selectedGroup;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Groups created by this user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function createdGroups()
|
||||
{
|
||||
return $this->hasMany(Group::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated groups.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function groups()
|
||||
{
|
||||
return $this->belongsToMany(Group::class, 'user_groups')->withPivot(['status', 'position']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create and return user's primary group.
|
||||
*
|
||||
* @return \App\Models\Group
|
||||
*/
|
||||
public function createOwnGroup()
|
||||
{
|
||||
$group = Group::create([
|
||||
'name' => $this->name,
|
||||
'invite_only' => true,
|
||||
'user_id' => $this->id,
|
||||
]);
|
||||
|
||||
$this->groups()->attach($group, [
|
||||
'status' => Group::$STATUS_OWN,
|
||||
]);
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current user's selected group.
|
||||
*
|
||||
* @return \App\Models\Group
|
||||
*/
|
||||
public function selectedGroup()
|
||||
{
|
||||
if ($this->selectedGroup === null) {
|
||||
$this->selectedGroup = $this->fetchSelectedGroup();
|
||||
}
|
||||
|
||||
return $this->selectedGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember user's selected group.
|
||||
*/
|
||||
public function setSelectedGroup(Group $group)
|
||||
{
|
||||
$this->selectedGroup = $group;
|
||||
|
||||
$this->storeSelectedGroup();
|
||||
|
||||
return $this->getFlatTree();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of groups user is active in.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function listActiveGroups()
|
||||
{
|
||||
$userId = $this->id;
|
||||
|
||||
return $this->groups()
|
||||
->select([
|
||||
'groups.id',
|
||||
'groups.name',
|
||||
])->active()
|
||||
->orderBy('position')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group status for current user. If group is not associated with
|
||||
* user, association will be made. It won't change user group if it is
|
||||
* marked as being owned or created by current user, unless $force is true.
|
||||
*
|
||||
* @param string $newStatus
|
||||
* @param mixed $force
|
||||
*/
|
||||
public function updateGroupStatus(Group $group, $newStatus, $force = false)
|
||||
{
|
||||
$userGroup = $this->groups()->find($group->id);
|
||||
|
||||
if ($userGroup) {
|
||||
if (in_array($userGroup->pivot->status, [Group::$STATUS_OWN, Group::$STATUS_CREATED]) && !$force) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->groups()->updateExistingPivot($group->id, [
|
||||
'status' => $newStatus,
|
||||
]);
|
||||
} else {
|
||||
$this->groups()->save($group, [
|
||||
'status' => $newStatus,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key to access reminded selected group for this user.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function selectedGroupStoreKey()
|
||||
{
|
||||
return sprintf('selectedGroup.%d', $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return stored user's selected group.
|
||||
*
|
||||
* @return \App\Models\Group
|
||||
*/
|
||||
protected function fetchSelectedGroup()
|
||||
{
|
||||
$key = $this->selectedGroupStoreKey();
|
||||
|
||||
if (cache()->has($key)) {
|
||||
$group = $this->groups()->active()->find(cache($key));
|
||||
|
||||
if (!empty($group)) {
|
||||
return $group;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->groups()->own()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user's selected group.
|
||||
*/
|
||||
protected function storeSelectedGroup()
|
||||
{
|
||||
$key = $this->selectedGroupStoreKey();
|
||||
|
||||
cache()->forever($key, $this->selectedGroup->id);
|
||||
}
|
||||
}
|
||||
125
app/Models/User.php
Executable file
125
app/Models/User.php
Executable file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\User\HasFeeds;
|
||||
use App\Models\Traits\User\HasFolders;
|
||||
use App\Models\Traits\User\HasGroups;
|
||||
use App\Services\Importer;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Contracts\Translation\HasLocalePreference;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail, HasLocalePreference
|
||||
{
|
||||
use Notifiable;
|
||||
use HasGroups;
|
||||
use HasFolders;
|
||||
use HasFeeds;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Properties |-------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name', 'email', 'password', 'lang'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for arrays.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getLangAttribute() {
|
||||
if(!empty($this->attributes['lang'])) {
|
||||
return trim($this->attributes['lang']);
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Relations |--------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Documents added to user's collection.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->hasManyThrough(Bookmark::class, Folder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights registered by this user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function highlights()
|
||||
{
|
||||
return $this->hasMany(Highlight::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associated history entries.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function userHistoryEntries()
|
||||
{
|
||||
return $this->hasMany(HistoryEntry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions affected to this user.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function permissions()
|
||||
{
|
||||
return $this->hasMany(Permission::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ----| Methods |----------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the user's preferred locale.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function preferredLocale()
|
||||
{
|
||||
return $this->lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import initial set of data.
|
||||
*/
|
||||
public function importInitialData(Group $group)
|
||||
{
|
||||
$importer = new Importer();
|
||||
$importer->forUser($this)->inGroup($group)->fromFile(resource_path('initial_data.json'))->import();
|
||||
}
|
||||
}
|
||||
83
app/Notifications/AsksToJoinGroup.php
Executable file
83
app/Notifications/AsksToJoinGroup.php
Executable file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AsksToJoinGroup extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Inviting user.
|
||||
*
|
||||
* @var \App\Models\User
|
||||
*/
|
||||
protected $user;
|
||||
|
||||
/**
|
||||
* Group to invite a user in.
|
||||
*
|
||||
* @var \App\Models\Group
|
||||
*/
|
||||
protected $group;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @param \App\Models\User $user User asking to join group
|
||||
*/
|
||||
public function __construct(User $user, Group $group)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->group = $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
return (new MailMessage())
|
||||
->from(env('MAIL_FROM_ADDRESS'))
|
||||
->line(sprintf('Hello there ! %s wants to join a group you created in Cyca: %s.', $this->user->name, $this->group->name))
|
||||
->action(sprintf('Accept %s in %s', $this->user->name, $this->group->name), URL::signedRoute('group.signed_approve_user', ['user' => $this->user->id, 'group' => $this->group->id]))
|
||||
->line('You can safely ignore this email if you prefer to decline.')
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
65
app/Notifications/DocumentUpdated.php
Executable file
65
app/Notifications/DocumentUpdated.php
Executable file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class DocumentUpdated extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Updated document.
|
||||
*
|
||||
* @var \App\Models\Document
|
||||
*/
|
||||
public $document;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(Document $document)
|
||||
{
|
||||
$this->document = $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['broadcast'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
$this->document->loadMissing('feeds');
|
||||
|
||||
return ['document' => $this->document->toArray()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which queues should be used for each notification channel.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function viaQueues()
|
||||
{
|
||||
return [
|
||||
'broadcast' => 'notifications',
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Notifications/FeedUpdated.php
Executable file
55
app/Notifications/FeedUpdated.php
Executable file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class FeedUpdated extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['broadcast'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which queues should be used for each notification channel.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function viaQueues()
|
||||
{
|
||||
return [
|
||||
'broadcast' => 'notifications',
|
||||
];
|
||||
}
|
||||
}
|
||||
84
app/Notifications/InvitedToJoinGroup.php
Executable file
84
app/Notifications/InvitedToJoinGroup.php
Executable file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class InvitedToJoinGroup extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Inviting user.
|
||||
*
|
||||
* @var \App\Models\User
|
||||
*/
|
||||
protected $user;
|
||||
|
||||
/**
|
||||
* Group to invite a user in.
|
||||
*
|
||||
* @var \App\Models\Group
|
||||
*/
|
||||
protected $group;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @param \App\Models\User $user Inviting user
|
||||
*/
|
||||
public function __construct(User $user, Group $group)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->group = $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
return (new MailMessage())
|
||||
->from($this->user->email)
|
||||
->line(sprintf('Hello there ! You have been invited by %s (%s) to join the %s group in Cyca.', $this->user->name, $this->user->email, $this->group->name))
|
||||
->action('Accept invitation', URL::signedRoute('group.signed_accept_invitation', ['group' => $this->group->id]))
|
||||
->line('If you already have an account on Cyca, you can decline this invitation in your user account.')
|
||||
->line('If you do not already have an account on Cyca, you can register then click again on this link. If you do not want to join the group, you can safely ignore this message.')
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Notifications/UnreadItemsChanged.php
Executable file
79
app/Notifications/UnreadItemsChanged.php
Executable file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\BroadcastMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class UnreadItemsChanged extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Feed items, feeds, documents or folders to recalculate unread items for.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = [];
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @param null|array Feed items, feeds, documents or folders to recalculate
|
||||
* unread items for
|
||||
* @param null|mixed $data
|
||||
*/
|
||||
public function __construct($data = null)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['broadcast'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return $notifiable->countUnreadItems($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the broadcastable representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return BroadcastMessage
|
||||
*/
|
||||
public function toBroadcast($notifiable)
|
||||
{
|
||||
return (new BroadcastMessage($this->toArray($notifiable)))->onQueue('notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which queues should be used for each notification channel.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function viaQueues()
|
||||
{
|
||||
return [
|
||||
'broadcast' => 'notifications',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Executable file
24
app/Providers/AppServiceProvider.php
Executable file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
// Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
// Illuminate\Support\Facades\File;
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
}
|
||||
}
|
||||
16
app/Providers/AuthServiceProvider.php
Executable file
16
app/Providers/AuthServiceProvider.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any authentication / authorization services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
$this->registerPolicies();
|
||||
}
|
||||
}
|
||||
39
app/Providers/BladeServiceProvider.php
Executable file
39
app/Providers/BladeServiceProvider.php
Executable file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BladeServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
$this->registerHighlights();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register user's highlights into view.
|
||||
*/
|
||||
protected function registerHighlights()
|
||||
{
|
||||
view()->composer('*', function ($view) {
|
||||
if (!auth()->check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$highlights = auth()->user()->highlights()->select(['id', 'expression', 'color', 'position'])->orderBy('position')->get();
|
||||
|
||||
view()->share('highlights', $highlights);
|
||||
});
|
||||
}
|
||||
}
|
||||
19
app/Providers/BroadcastServiceProvider.php
Executable file
19
app/Providers/BroadcastServiceProvider.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BroadcastServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
Broadcast::routes();
|
||||
|
||||
require base_path('routes/channels.php');
|
||||
}
|
||||
}
|
||||
30
app/Providers/EventServiceProvider.php
Executable file
30
app/Providers/EventServiceProvider.php
Executable file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event listener mappings for the application.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $listen = [
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any events for your application.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
}
|
||||
51
app/Providers/FortifyServiceProvider.php
Executable file
51
app/Providers/FortifyServiceProvider.php
Executable file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
|
||||
Fortify::loginView(function () {
|
||||
return view('auth.login');
|
||||
});
|
||||
|
||||
Fortify::registerView(function () {
|
||||
return view('auth.register');
|
||||
});
|
||||
|
||||
Fortify::requestPasswordResetLinkView(function () {
|
||||
return view('auth.forgot-password');
|
||||
});
|
||||
|
||||
Fortify::resetPasswordView(function ($request) {
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
});
|
||||
|
||||
Fortify::verifyEmailView(function () {
|
||||
return view('auth.verify-email');
|
||||
});
|
||||
}
|
||||
}
|
||||
35
app/Providers/LangServiceProvider.php
Executable file
35
app/Providers/LangServiceProvider.php
Executable file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class LangServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
view()->composer('*', function ($view) {
|
||||
$strings = '';
|
||||
|
||||
if (auth()->check()) {
|
||||
$langFile = resource_path(sprintf('lang/%s.json', auth()->user()->lang));
|
||||
|
||||
if (file_exists($langFile)) {
|
||||
$strings = json_decode(file_get_contents($langFile));
|
||||
}
|
||||
}
|
||||
|
||||
view()->share('langStrings', $strings);
|
||||
});
|
||||
}
|
||||
}
|
||||
39
app/Providers/ObserversServiceProvider.php
Executable file
39
app/Providers/ObserversServiceProvider.php
Executable file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Bookmark;
|
||||
use App\Models\Document;
|
||||
use App\Models\Feed;
|
||||
use App\Models\FeedItem;
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
use App\Models\IgnoredFeed;
|
||||
use App\Models\Observers\BookmarkObserver;
|
||||
use App\Models\Observers\DocumentObserver;
|
||||
use App\Models\Observers\FeedItemObserver;
|
||||
use App\Models\Observers\FeedObserver;
|
||||
use App\Models\Observers\FolderObserver;
|
||||
use App\Models\Observers\GroupObserver;
|
||||
use App\Models\Observers\IgnoredFeedObserver;
|
||||
use App\Models\Observers\UserObserver;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ObserversServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
User::observe(UserObserver::class);
|
||||
Document::observe(DocumentObserver::class);
|
||||
Feed::observe(FeedObserver::class);
|
||||
Folder::observe(FolderObserver::class);
|
||||
Bookmark::observe(BookmarkObserver::class);
|
||||
IgnoredFeed::observe(IgnoredFeedObserver::class);
|
||||
Group::observe(GroupObserver::class);
|
||||
FeedItem::observe(FeedItemObserver::class);
|
||||
}
|
||||
}
|
||||
67
app/Providers/RouteServiceProvider.php
Executable file
67
app/Providers/RouteServiceProvider.php
Executable file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The path to the "home" route for your application.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const HOME = '/';
|
||||
/**
|
||||
* This namespace is applied to your controller routes.
|
||||
*
|
||||
* In addition, it is set as the URL generator's root namespace.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = 'App\Http\Controllers';
|
||||
|
||||
/**
|
||||
* Define your route model bindings, pattern filters, etc.
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*/
|
||||
public function map()
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*/
|
||||
protected function mapWebRoutes()
|
||||
{
|
||||
Route::middleware('web')
|
||||
->namespace($this->namespace)
|
||||
->group(base_path('routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*/
|
||||
protected function mapApiRoutes()
|
||||
{
|
||||
Route::prefix('api')
|
||||
->middleware('api')
|
||||
->namespace($this->namespace)
|
||||
->group(base_path('routes/api.php'));
|
||||
}
|
||||
}
|
||||
170
app/Services/Exporter.php
Executable file
170
app/Services/Exporter.php
Executable file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Folder;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Exports data from Cyca.
|
||||
*/
|
||||
class Exporter
|
||||
{
|
||||
/**
|
||||
* Should we include highlights in the export ?
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $withHighlights = true;
|
||||
|
||||
/**
|
||||
* Should we include bookmarks in the export ?
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $withBookmarks = true;
|
||||
|
||||
/**
|
||||
* User to import data for.
|
||||
*
|
||||
* @var \App\Models\User
|
||||
*/
|
||||
protected $forUser;
|
||||
|
||||
/**
|
||||
* Folder to export bookmarks and feeds from.
|
||||
*
|
||||
* @var \App\Models\Folder
|
||||
*/
|
||||
protected $fromFolder;
|
||||
|
||||
/**
|
||||
* Should we ignore highlights during export ? By default, highlights will
|
||||
* be exported as well.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function withoutHighlights()
|
||||
{
|
||||
$this->withHighlights = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we ignore bookmarks during export ? By default, bookmarks will
|
||||
* be exported as well.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function withoutBookmarks()
|
||||
{
|
||||
$this->withoutBookmarks = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines which user to export data for. If not defined, user will be
|
||||
* extracted from request.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function forUser(User $user)
|
||||
{
|
||||
$this->forUser = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines from which folder data will be exported. If not defined, root
|
||||
* folder attached to specified user will be used.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function fromFolder(Folder $folder)
|
||||
{
|
||||
$this->fromFolder = $folder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export specified user's data into a PHP array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
if (empty($this->fromFolder)) {
|
||||
$this->fromFolder = $this->forUser->groups()->active()->first()->folders()->ofType('root')->first();
|
||||
}
|
||||
|
||||
$rootArray = [
|
||||
'documents' => [],
|
||||
'folders' => $this->exportTree($this->fromFolder->children()->get()),
|
||||
];
|
||||
|
||||
foreach ($this->fromFolder->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 [
|
||||
'highlights' => $this->forUser->highlights()->select(['expression', 'color'])->get(),
|
||||
'bookmarks' => $rootArray,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a single tree branch.
|
||||
*
|
||||
* @param mixed $folders
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
321
app/Services/Importer.php
Executable file
321
app/Services/Importer.php
Executable file
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Folder;
|
||||
use App\Models\Group;
|
||||
use App\Models\Highlight;
|
||||
use App\Models\IgnoredFeed;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Imports data in Cyca.
|
||||
*/
|
||||
class Importer
|
||||
{
|
||||
/**
|
||||
* Adapter used to import data.
|
||||
*
|
||||
* @var \App\Contracts\ImportAdapter
|
||||
*/
|
||||
protected $importAdapter;
|
||||
|
||||
/**
|
||||
* User to import data for.
|
||||
*
|
||||
* @var \App\Models\User
|
||||
*/
|
||||
protected $forUser;
|
||||
|
||||
/**
|
||||
* Folder to import bookmarks and feeds to.
|
||||
*
|
||||
* @var \App\Models\Folder
|
||||
*/
|
||||
protected $inFolder;
|
||||
|
||||
/**
|
||||
* Should we import highlights as well ?
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $withHighlights = true;
|
||||
|
||||
/**
|
||||
* Data to be imported as an array.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dataArray = [];
|
||||
|
||||
/**
|
||||
* Group to import data to.
|
||||
*
|
||||
* @var \App\Models\Group
|
||||
*/
|
||||
protected $inGroup;
|
||||
|
||||
/**
|
||||
* Indicates which adapter to use for importation.
|
||||
*
|
||||
* @param string $adapterName
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function using($adapterName)
|
||||
{
|
||||
$className = config(sprintf('importers.adapters.%s.adapter', $adapterName));
|
||||
|
||||
if (empty($className)) {
|
||||
abort(422, sprintf('Unknown import adapter %s', $className));
|
||||
}
|
||||
|
||||
$this->importAdapter = new $className();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines which user to import data for. If not defined, user will be
|
||||
* extracted from request.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function forUser(User $user)
|
||||
{
|
||||
$this->forUser = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the group to import data to.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function inGroup(Group $group)
|
||||
{
|
||||
$this->inGroup = $group;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines in which folder data will be imported to. If not defined, root
|
||||
* folder attached to specified user will be used.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function inFolder(Folder $folder)
|
||||
{
|
||||
$this->inFolder = $folder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we ignore highlights during import ? By default, highlights will
|
||||
* be imported as well.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function withoutHighlights()
|
||||
{
|
||||
$this->withHighlights = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import data from specified file. Must be a valid json file, valid from
|
||||
* Cyca's architecture point of view.
|
||||
*
|
||||
* @param string $path Full path to file to import
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function fromFile($path)
|
||||
{
|
||||
if (empty($this->forUser)) {
|
||||
abort(422, 'Target user not specified');
|
||||
}
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
if (empty($contents)) {
|
||||
abort(422, 'File does not exists');
|
||||
}
|
||||
|
||||
$this->dataArray = json_decode($contents, true);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import data using current request informations.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function fromRequest(Request $request)
|
||||
{
|
||||
if (empty($this->importAdapter)) {
|
||||
$this->using($request->input('importer'));
|
||||
}
|
||||
|
||||
if (empty($this->importAdapter)) {
|
||||
abort(422, 'An import adapter must be specified');
|
||||
}
|
||||
|
||||
if (empty($this->forUser)) {
|
||||
$this->forUser = $request->user();
|
||||
}
|
||||
|
||||
$this->dataArray = $this->importAdapter->importFromRequest($request);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the import.
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
if (empty($this->inGroup)) {
|
||||
$this->inGroup = $this->forUser->groups()->wherePivot('status', '=', 'own')->first();
|
||||
}
|
||||
|
||||
if (empty($this->inFolder)) {
|
||||
$this->inFolder = $this->inGroup->folders()->ofType('root')->first();
|
||||
}
|
||||
|
||||
if ($this->withHighlights && !empty($this->dataArray['highlights'])) {
|
||||
$this->importHighlights($this->dataArray['highlights']);
|
||||
}
|
||||
|
||||
if (!empty($this->dataArray['bookmarks'])) {
|
||||
$this->importBookmarks($this->dataArray['bookmarks']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import highlights from specified array.
|
||||
*
|
||||
* @param array $highlights
|
||||
*/
|
||||
protected function importHighlights($highlights)
|
||||
{
|
||||
foreach ($highlights as $highlightData) {
|
||||
$highlight = Highlight::where('user_id', $this->forUser->id)->where('expression', $highlightData['expression'])->first();
|
||||
|
||||
if (!$highlight) {
|
||||
$highlight = new Highlight();
|
||||
|
||||
$highlight->user_id = $this->forUser->id;
|
||||
$highlight->expression = $highlightData['expression'];
|
||||
$highlight->color = $highlightData['color'];
|
||||
|
||||
$highlight->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import bookmarks from specified array.
|
||||
*
|
||||
* @param array $bookmarks
|
||||
*/
|
||||
protected function importBookmarks($bookmarks)
|
||||
{
|
||||
$this->importDocuments($this->inFolder, $bookmarks['documents'] ?: []);
|
||||
$this->importFolders($this->inFolder, $bookmarks['folders'] ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import folders.
|
||||
*
|
||||
* @param \App\Models\Folder Destination folder
|
||||
* @param array $foldersData Array of sub-folders definitions
|
||||
* @param mixed $folder
|
||||
*/
|
||||
protected function importFolders($folder, $foldersData)
|
||||
{
|
||||
foreach ($foldersData as $folderData) {
|
||||
$children = $this->inGroup->folders()->save(new Folder([
|
||||
'title' => $folderData['title'],
|
||||
'parent_id' => $folder->id,
|
||||
'user_id' => $this->forUser->id,
|
||||
]));
|
||||
|
||||
$this->importDocuments($children, $folderData['documents']);
|
||||
$this->importFolders($children, $folderData['folders']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import documents.
|
||||
*
|
||||
* @param \App\Models\Folder Destination folder
|
||||
* @param array $documentsData Array of documents definitions
|
||||
* @param mixed $folder
|
||||
*/
|
||||
protected function importDocuments($folder, $documentsData)
|
||||
{
|
||||
foreach ($documentsData as $docData) {
|
||||
if (empty($docData['url'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = urldecode($docData['url']);
|
||||
$document = Document::firstOrCreate(['url' => $url]);
|
||||
|
||||
if (array_key_exists('feeds', $docData)) {
|
||||
$this->importFeeds($document, $docData['feeds']);
|
||||
}
|
||||
|
||||
$folder->documents()->save($document, [
|
||||
'initial_url' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import feeds.
|
||||
*
|
||||
* @param \App\Models\Document $document Destination document
|
||||
* @param array $feedsData Array of feeds definitions
|
||||
*/
|
||||
protected function importFeeds($document, $feedsData)
|
||||
{
|
||||
$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', $this->forUser->id)->where('feed_id', $feed->id)->first();
|
||||
|
||||
if (!$ignoredFeed) {
|
||||
$ignoredFeed = new IgnoredFeed();
|
||||
|
||||
$ignoredFeed->user()->associate($this->forUser);
|
||||
$ignoredFeed->feed()->associate($feed);
|
||||
|
||||
$ignoredFeed->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$document->feeds()->sync($feedsToAttach);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user