Add Back-Office

This commit is contained in:
Jérémy Munsch 2023-04-21 14:49:53 +00:00 • committed by Vincent BRIDIER
parent c170c3d5cb
commit 61c8000cab
301 changed files with 32676 additions and 1575 deletions

View file

@ -16,3 +16,6 @@ indent_size = 2
[docker-compose.yml]
indent_size = 4
[*.vue]
indent_size = 2

View file

@ -18,7 +18,7 @@ DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
QUEUE_CONNECTION=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
@ -32,7 +32,10 @@ MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
FACEBOOK_GRAPH_ACCESS_TOKEN=""
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_IGHASHTAGID=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_HASHTAGID=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_USERID=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_LIMIT=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIAS=
# The amount of media to acquire
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIA=
# Sentry
SENTRY_LARAVEL_DSN=

55
.eslintrc.json Normal file
View file

@ -0,0 +1,55 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-strongly-recommended",
"@vue/eslint-config-typescript"
],
"overrides": [
{
"files": ["*.ts", "*.vue"],
"rules": {
"no-undef": "off"
}
},
{
"files": ["*.vue"],
"rules": {
"indent": "off"
}
}
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["vue", "@typescript-eslint"],
"ignorePatterns": ["webpack.mix.js", "**/vendor/*.js", "**/vendor/**/*.js"],
"rules": {
"indent": ["error", 4, { "SwitchCase": 1 }],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double"],
"semi": ["error", "always"],
"vue/script-indent": ["error", 2, { "baseIndent": 0, "switchCase": 1 }],
"vue/one-component-per-file": "off"
},
"settings": {
"import/resolver": {
"node": {
"extensions": [".js", ".vue", ".ts", ".d.ts"]
},
"alias": {
"extensions": [".vue", ".js", ".ts", ".scss", ".d.ts"],
"map": [
["~", "./resources/assets"],
["@", "./node_modules"]
]
}
}
}
}

2
.gitattributes vendored
View file

@ -1,4 +1,4 @@
* text=auto
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css

27
.gitignore vendored
View file

@ -1,18 +1,23 @@
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
.stylelintcache
/.fleet
/.idea
/.phpunit.cache
/.vscode
/node_modules
/public/build
/public/cache
/public/hot
/public/sitemap.xml
/public/storage
/storage/*.key
/vendor
auth.json
docker-compose.override.yml
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log

24
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,24 @@
include:
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'lint/lint.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'security/security.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'build/build.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'test/test.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'deploy/deploy.yml'
stages:
- lint
- security
- build
- test
- deploy

43
.stylelintrc.json Normal file
View file

@ -0,0 +1,43 @@
{
"extends": [
"stylelint-config-prettier",
"stylelint-config-standard-scss",
"stylelint-config-sass-guidelines",
"stylelint-config-idiomatic-order"
],
"plugins": ["stylelint-scss"],
"files": ["**/*.css", "**/*.scss"],
"ignoreFiles": [
"**/vendor/*.css",
"**/vendor/**/*.css",
"**/vendor/*.scss",
"**/vendor/**/*.scss",
"**/vendor/*.sass",
"**/vendor/**/*.sass",
"**/vendor/*.less",
"**/vendor/**/*.less"
],
"customSyntax": "postcss-scss",
"rules": {
"indentation": 4,
"string-quotes": "double",
"selector-max-id": null,
"color-no-invalid-hex": true,
"order/properties-alphabetical-order": null,
"max-nesting-depth": 5,
"selector-max-compound-selectors": 5,
"declaration-colon-space-after": "always-single-line",
"function-parentheses-space-inside": "never-single-line",
"declaration-colon-newline-after": null,
"scss/double-slash-comment-empty-line-before": [
"always",
{
"ignore": [
"between-comments",
"stylelint-commands",
"inside-block"
]
}
]
}
}

View file

@ -12,14 +12,12 @@ php artisan migrate
.env
```
FACEBOOK_GRAPH_ACCESS_TOKEN=""
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_IGHASHTAGID=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_HASHTAGID=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_USERID=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_LIMIT=
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIAS=
# The amount of media to acquire
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIA=
```
Commande :
```
php artisan insta:get-top-medias
```
cette commande appelle le Job : InstaGetTopMediasJob
### TODO:
- Permettre la recherche d'un hashtag et d'utiliser son id pour topMediaList
- Refresh le token en automatique si detection expired

View file

@ -0,0 +1,177 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class CreateAdmin extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:create';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a backend admin user';
/**
* The firstname of the new user.
*
* @var string
*/
protected $firstname;
/**
* The lastname of the new user.
*
* @var string
*/
protected $lastname;
/**
* The email of the new user.
*
* @var string
*/
protected $email;
/**
* The password of the new user.
*
* @var string
*/
protected $password;
/**
* Execute the console command.
*
* @return integer
*/
public function handle(): int
{
$this->firstname = null;
$this->addFirstname();
$this->lastname = null;
$this->addLastname();
$this->email = null;
$this->addEmail();
$this->password = null;
$this->addPassword();
$user = new User([
'firstname' => $this->firstname,
'lastname' => $this->lastname,
'email' => $this->email,
'password' => $this->password,
]);
$user->saveOrFail();
$this->info('User created 👌');
return 0;
}
/**
* Add a firstname to the user.
*
* @return void
*/
private function addFirstname(): void
{
while (is_null($this->firstname)) {
try {
$tmp = $this->ask('Type the wanted user firstname', 'Admin');
$validator = Validator::make(['firstname' => $tmp], [
'firstname' => 'required|min:3|max:255'
]);
$validator->validate();
$this->firstname = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please provide a valid firstname %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
/**
* Add a lastname to the user.
*
* @return void
*/
private function addLastname(): void
{
while (is_null($this->lastname)) {
try {
$tmp = $this->ask('Type the wanted user lastname', 'Saïtama');
$validator = Validator::make(['lastname' => $tmp], [
'lastname' => 'required|min:3|max:255'
]);
$validator->validate();
$this->lastname = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please provide a valid lastname %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
/**
* Add an email to the user.
*
* @return void
*/
private function addEmail(): void
{
while (is_null($this->email)) {
try {
$tmp = $this->ask('Type the wanted user email', 'equipe.web@ldc.fr');
$validator = Validator::make(['email' => $tmp], [
'email' => 'required|unique:users,email|email:rfc,dns'
]);
$validator->validate();
$this->email = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please provide a valid email %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
/**
* Add a password to the user.
*
* @return void
*/
private function addPassword(): void
{
while (
($this->password = $this->secret(
(!\is_null($this->password) ? 'Failed to confirm, ' : '') . 'Type the wanted user password'
)) !== $this->secret('Confirm the password')
) {
continue;
}; //end while
}
}

View file

@ -1,35 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\InstaGetTopMediasJob;
use Illuminate\Console\Command;
class InstaGetTopMediasCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'insta:get-top-medias';
/**
* The console command description.
* https://developers.facebook.com/docs/instagram-api/reference/ig-hashtag/top-media
* @var string
*/
protected $description = 'Top Media identifié par un hashtag IG';
/**
* Execute the console command.
*
* @return integer
*/
public function handle()
{
$job = new InstaGetTopMediasJob();
$job->handle();
return 0;
}
}

View file

@ -4,7 +4,13 @@ namespace App\Enums;
enum ExcludeTypeEnum : Int
{
/**
* Ig post defined by its id
*/
case post = 1;
/**
* Ig user defined by its username
*/
case user = 2;
/**

View file

@ -12,16 +12,14 @@ class Handler extends ExceptionHandler
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [
];
protected $levels = [];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [
];
protected $dontReport = [];
/**
* A list of the inputs that are never flashed to the session on validation exceptions.
@ -42,6 +40,9 @@ class Handler extends ExceptionHandler
public function register()
{
$this->reportable(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
}
}

View file

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

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Bo\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
use AuthenticatesUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest:backend')->except('logout');
}
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\StatefulGuard
*/
protected function guard(): StatefulGuard
{
return Auth::guard('backend');
}
/**
* Show the application's login form.
*
* @return \Illuminate\Contracts\View\View
*/
public function showLoginForm(): View
{
return view('bo.auth.login');
}
/**
* Where to redirect users after login.
*
* @return string
*/
protected function redirectTo(): string
{
return response()->redirectToIntended()->getTargetUrl();
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Bo\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Support\Facades\Auth;
class ResetPasswordController extends Controller
{
use ResetsPasswords;
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\StatefulGuard
*/
protected function guard(): StatefulGuard
{
return Auth::guard('backend');
}
/**
* Where to redirect users after login.
*
* @return string
*/
protected function redirectTo(): string
{
return \route('bo.login');
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
class DashboardController extends Controller
{
/**
* Dashboard Page
*
* @return \Illuminate\Contracts\View\View
*/
public function index(): View
{
// * Redirect to 'bo.top-media-list.index' route.
\redirect()->route('bo.top-media-list.index')->throwResponse();
return view('bo.pages.dashboard');
}
}

View file

@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Enums\ExcludeTypeEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Ajax\ExcludeTopMediaRequest;
use App\Jobs\InstaGetTopMediasJob;
use App\Lib\Instagram\Objects\TopMedia;
use App\Models\InstaMedia;
use App\Models\InstaMediaExclusion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class TopMediaController extends Controller
{
/**
* Show paginated Top Media List
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View
*/
public function index(Request $request): View
{
/** @var \Illuminate\Database\Eloquent\Builder $topMediaList */
$topMediaList = InstaMedia::query();
$ranking = $this->getRanking();
if ($request->search) {
$this->searchQuery(
$topMediaList,
$request->search,
null,
'insta_id',
'username',
'permalink'
);
} //end if
$this->sortQuery($topMediaList);
$searchFields = trans('Id Instagram, Participant, Lien');
$topMediaList->orderBy('updated_at', 'DESC');
$topMediaList = $topMediaList->paginate();
return \view('bo.pages.top-media.index', \compact('topMediaList', 'searchFields', 'ranking'));
}
/**
* Show the refresh vue component
*
* @return \Illuminate\View\View
*/
public function refresh(): View
{
return \view('bo.pages.top-media.refresh');
}
// * Ajax Methods.
/**
* Fetch Insta media list from api
*
* @param boolean $refresh
* @return \Illuminate\Http\JsonResponse
*/
public function fetch(bool $refresh = false): JsonResponse
{
$responseData = [
'ready' => false
];
if (
Cache::get('top_media_job_fetch_dispatched') or
Cache::get('top_media_job_fetch_running')
) {
return response()->json($responseData);
}
if (empty(Cache::get('top_media_ajax_fetch')) or $refresh) {
\dispatch(new InstaGetTopMediasJob())->onConnection('database');
Cache::put('top_media_job_fetch_dispatched', true);
return response()->json($responseData);
}
$responseData['ready'] = true;
$responseData['data'] = Cache::get('top_media_ajax_fetch');
return \response()->json($responseData);
}
/**
* Fetch Media Picture Object
*
* @param string $ig_media_hash
* @return \Illuminate\Http\JsonResponse
*/
public function fetchPicture(string $ig_media_hash): JsonResponse
{
return \response()->json(Cache::remember(
"media_picture_ajax_fetch{$ig_media_hash}",
// * 3H cache
3 * 60 * 60,
fn () => InstaMedia::fetchInstagramMediaPictures($ig_media_hash)
));
}
/**
* Save the new list
*
* @return \Illuminate\Http\RedirectResponse
*/
public function save(): JsonResponse
{
return DB::transaction(function () {
/** @var \Illuminate\Support\Collection<\App\Lib\Instagram\Objects\TopMedia> */
$igMediaList = Cache::get('top_media_ajax_fetch');
// * 'Truncate' our list to preserve only the desired media amount.
// Dont use truncate here because it implies a commit.
InstaMedia::query()->delete();
$igMediaList->map(function (TopMedia $ig_media) {
(new InstaMedia((array)$ig_media))->saveOrFail();
});
\session()->flash('success', trans('Le classement à bien été mis à jour'));
return \response()->json();
});
}
/**
* Exclude a top Media or a username
*
* @param \App\Http\Requests\Bo\Ajax\ExcludeTopMediaRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function exclude(ExcludeTopMediaRequest $request): JsonResponse
{
$excludeData = [
'type' => $request->insta_id ? ExcludeTypeEnum::post : ExcludeTypeEnum::user,
'value' => $request->insta_id ?: $request->username
];
InstaMediaExclusion::query()->firstOrCreate($excludeData, $excludeData)->saveOrFail();
return \response()->json($excludeData);
}
/**
* Get Actual ranking
*
* @return \Illuminate\Support\Collection<App\Models\InstaMedia>
*/
private function getRanking(): Collection
{
return InstaMedia::query()->orderBy('id')->pluck('insta_id')->flip();
}
}

View file

@ -2,14 +2,122 @@
namespace App\Http\Controllers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
/**
* Build Search query on specified fields
* splitting search into words or using whole sentence
*
* @param \Illuminate\Database\Eloquent\Builder $query The eloquent query builder.
* @param string $search The query string.
* @param callable $searchQuery The query string.
* @param string ...$fields Either a simple array or a string
* in case of a string the two fields
* would be concatenate to make the search.
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function searchQuery(
Builder $query,
string $search,
callable $searchQuery = null,
string ...$fields
): Builder {
if (count($fields)) {
// Search using words.
$words = explode(' ', $search);
$query->where(function ($query) use ($searchQuery, $words, $search, $fields) {
foreach ($words as $word) {
if (!strlen($word)) {
continue;
}
$this->searchOnFields($query, $word, $fields);
} //end foreach
// Search whole using whole sentence.
$this->searchOnFields($query, $search, $fields);
if ($searchQuery) {
$searchQuery($query);
}
});
} //end if
return $query;
}
/**
* Build query search on all fields
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $search
* @param array $fields
* @return \Illuminate\Database\Eloquent\Builder
*/
private function searchOnFields(Builder $query, string $search, array $fields): Builder
{
$table = $query->getModel()->getTable();
foreach ($fields as $f) {
$f = str_replace('?', '\?', $f);
if (is_array($f)) {
$query->orWhereRaw(
sprintf("UPPER(CONCAT(%s)) LIKE UPPER(?)", collect($f)->map(function ($q) use ($table) {
return "`$table`.`$q`";
})->implode(', \' \', ')),
['%' . filter_var($search, FILTER_SANITIZE_STRING) . '%']
);
} else {
$query->orWhereRaw(
"UPPER(`$table`.`$f`) LIKE UPPER(?)",
['%' . filter_var($search, FILTER_SANITIZE_STRING) . '%']
);
}
}
return $query;
}
/**
* Sort columns with a query
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $ignore
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function sortQuery(Builder $query, array $ignore = []): Builder
{
$rName = request()->route()->getName();
$table = $query->getModel()->getTable();
if (request()->sort_col) {
Session::put("$rName.sort_col", request()->sort_col);
Session::put("$rName.sorted", true);
}
if (request()->sort_way) {
Session::put("$rName.sort_way", request()->sort_way);
Session::put("$rName.sorted", true);
}
if (request()->rst) {
Session::remove("$rName.sorted");
Session::remove("$rName.sort_col");
Session::remove("$rName.sort_way");
}
if (request()->sort_col and request()->sort_way and Schema::hasColumn($table, request()->sort_col)) {
if (
!isset($ignore) or
(isset($ignore) and !in_array(request()->sort_col, $ignore))
) {
$query = $query->orderBy(\sprintf("$table.%s", request()->sort_col), request()->sort_way);
}
} elseif (Schema::hasColumn($table, 'order')) {
$query = $query->orderBy('order', 'ASC');
Session::put("$rName.sort_col", 'order');
Session::put("$rName.sort_way", 'asc');
}
return $query;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Fo;
use App\Http\Controllers\Controller;
class HomeController extends Controller
{
/**
* Redirect to BO
*
* @return \Illuminate\Http\RedirectResponse
*/
public function index(): \Illuminate\Http\RedirectResponse
{
return \redirect()->route('bo.dashboard');
}
}

View file

@ -14,7 +14,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
@ -30,16 +30,18 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
'htmlsanitize',
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\SecureHeaders::class
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
@ -51,7 +53,7 @@ class Kernel extends HttpKernel
*
* @var array<string, class-string|string>
*/
protected $routeMiddleware = [
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
@ -62,5 +64,6 @@ class Kernel extends HttpKernel
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'htmlsanitize' => \App\Http\Middleware\HtmlSanitizer::class,
];
}

View file

@ -3,6 +3,8 @@
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
class Authenticate extends Middleware
{
@ -12,10 +14,27 @@ class Authenticate extends Middleware
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
protected function redirectTo(Request $request): ?string
{
if (! $request->expectsJson()) {
$logins = [
'backend' => route('bo.login')
];
if (!$request->expectsJson()) {
// * Notify must login
Session::flash('warning', trans('Veuillez vous connecter pour continuer'));
foreach ($request->route()->gatherMiddleware() as $middleware) {
if (\is_string($middleware) and strpos($middleware, 'auth:') !== false) {
$route = $logins[str_replace('auth:', '', $middleware)] ?? null;
if ($route) {
return $route;
}
}
}
return route('login');
}
abort(401);
return null;
}
}

View file

@ -11,6 +11,5 @@ class EncryptCookies extends Middleware
*
* @var array<int, string>
*/
protected $except = [
];
protected $except = [];
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Middleware;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HtmlSanitizer
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|array ...$params
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle(Request $request, \Closure $next, string|array ...$params): Response
{
$inputs = $request->all();
/** @var callable|null */
$cleanInput = null;
$cleanInput = function (
null|string|array &$input,
string $name,
?string $rootName = null
) use (
$params,
&$cleanInput
): void {
// * Nested element array
if (is_array($input)) {
array_walk_recursive($input, $cleanInput, $name);
}
if (!is_string($input)) {
return;
}
if (in_array($name, $params) or in_array($rootName ?? $name, $params)) {
// ? Is a json input ?
if ($isJson = \json_decode($input, true) and is_array($isJson)) {
// ! Decode each json field recursive.
\array_walk_recursive($isJson, function (string|array &$item) {
if (\is_string($item)) {
$item = clean($item);
$item = ToolboxHelper::stripCkEditorSchemesOnMediaInputs($item);
}
});
} else {
$input = clean($input);
$input = ToolboxHelper::stripCkEditorSchemesOnMediaInputs($input);
}
} else {
$input = strip_tags($input);
}
};
\array_walk($inputs, $cleanInput);
$request->merge($inputs);
return $next($request);
}
}

View file

@ -11,6 +11,5 @@ class PreventRequestsDuringMaintenance extends Middleware
*
* @var array<int, string>
*/
protected $except = [
];
protected $except = [];
}

View file

@ -2,28 +2,37 @@
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @param string|null ...$guards
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param string|null ...$guards
* @return \Symfony\Component\HttpFoundation\Response
* @throws \RuntimeException If guard is unhandled.
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function handle(Request $request, Closure $next, ...$guards)
public function handle(Request $request, \Closure $next, ...$guards): Response
{
// phpcs:enable phpcs:disable
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
switch ($guard) {
case 'frontend':
return \redirect()->route('home');
case 'backend':
return \redirect()->route('bo.dashboard');
default:
throw new \RuntimeException("Unhandled guard `{$guard}` .");
}
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class SecureHeaders
{
/**
* Enumerate headers which you do not want in your application's responses.
* Great starting point would be to go check out @Scott_Helme's:
* https://securityheaders.com/
*
* @var array
*/
private $unwantedHeaderList = [
'X-Powered-By',
'Server',
];
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @return \Symfony\Component\HttpFoundation\Response
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function handle(Request $request, Closure $next): Response
{
// phpcs:enable
View::share('nonce', $nonce = Vite::useCspNonce());
// * For VueJS dev tool to execute
$jsDev = config('app.debug') ? "'unsafe-eval' 'unsafe-inline'" : "'nonce-{$nonce}' 'strict-dynamic'";
$scheme = config('app.env') === 'local' ? 'http' : 'https';
$reportUri = Route::has('cspReportUri') ?
\sprintf(' report-uri %s ;', \parse_url(route('cspReportUri'), \PHP_URL_PATH)) : '';
$this->removeUnwantedHeaders($this->unwantedHeaderList);
$response = $next($request);
$response->headers->set('Referrer-Policy', 'no-referrer-when-downgrade');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains;');
$cspPolicies = [
"default-src 'none';",
"manifest-src 'self';",
// * CKEDITOR Specific https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/csp.html
"connect-src 'self' blob:;",
"img-src * data: blob:;",
'frame-src ' . implode(' ', [
'https://www.google.com/recaptcha/',
'https://recaptcha.google.com/recaptcha/',
'https://www.google.com/maps/'
]) . ';',
"font-src 'self' data: https://fonts.gstatic.com;",
// ! Using CKEditor will must have unsafe-inline style
// phpcs:ignore Generic.Files.LineLength.TooLong
"style-src 'self' 'unsafe-inline' " . \implode(' ', [
'https://cdnjs.cloudflare.com',
// * File Manager
'https://fonts.googleapis.com',
'https://cdn.jsdelivr.net',
'https://use.fontawesome.com',
'https://fonts.googleapis.com'
]) . ';',
// ! If you dont use ckeditor use this
// "style-src 'self' 'nonce-$nonce';",
// phpcs:ignore Generic.Files.LineLength.TooLong
"script-src 'self' {$jsDev} " . \implode(' ', [
'https: blob:',
'https://www.googletagmanager.com',
'https://cdnjs.cloudflare.com',
"$scheme://www.google-analytics.com",
]) . ';'
];
$response->headers->set(
'Content-Security-Policy',
implode(' ', $cspPolicies) . $reportUri
);
$response->headers->set('Feature-Policy', 'fullscreen \'self\';');
return $response;
}
/**
* Remove unwanted headers
*
* @param array $headerList
* @return void
*/
private function removeUnwantedHeaders(array $headerList)
{
foreach ($headerList as $header) {
header_remove($header);
}
}
}

View file

@ -11,12 +11,5 @@ class ValidateSignature extends Middleware
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
protected $except = [];
}

View file

@ -12,5 +12,6 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string>
*/
protected $except = [
];
'/csp-report'
];
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Bo\Ajax;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Cache;
class ExcludeTopMediaRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth('backend')->check() and $this->ajax();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'insta_id' => [
'required_without:username', 'digits_between:17,18',
function ($attribute, $value, $fail) {
/** @var \Illuminate\Support\Collection<\App\Lib\Instagram\Objects\TopMedia> */
$igMediaList = Cache::get('top_media_ajax_fetch');
if (
!$igMediaList->contains(function ($lValue) use ($value) {
/** @var \App\Lib\Instagram\Objects\TopMedia $lValue */
return $lValue->insta_id === \intval($value);
})
) {
$fail("$attribute Le media n'existe pas dans la liste en cache");
}
}
],
'username' => [
'required_without:insta_id', 'string', 'min:1',
function ($attribute, $value, $fail) {
/** @var \Illuminate\Support\Collection<\App\Lib\Instagram\Objects\TopMedia> */
$igMediaList = Cache::get('top_media_ajax_fetch');
if (
!$igMediaList->contains(function ($lValue) use ($value) {
/** @var \App\Lib\Instagram\Objects\TopMedia $lValue */
return $lValue->username === $value;
})
) {
$fail("$attribute Le media n'existe pas dans la liste en cache");
}
}
],
];
}
}

View file

View file

@ -3,16 +3,21 @@
namespace App\Jobs;
use App\Models\InstaMedia;
use App\Models\InstaMediaExclude;
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;
use App\Enums\ExcludeTypeEnum;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class InstaGetTopMediasJob implements ShouldQueue
/**
* Fetch and store the top media list of a specific ig keyword.
*
* @package App\Jobs
*/
class InstaGetTopMediasJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable;
use InteractsWithQueue;
@ -20,214 +25,35 @@ class InstaGetTopMediasJob implements ShouldQueue
use SerializesModels;
/**
* $url
* @var string
*/
private $url;
/**
* $version
* @var string
*/
private $version;
/**
* $access_token
* @var string
*/
private $access_token;
/**
* $fields
* @var string
*/
private $fields;
/**
* $ig_hashtag_id
* @var string
*/
private $ig_hashtag_id;
/**
* $user_id
* @var string
*/
private $user_id;
/**
* $limit
* @var integer
*/
private $limit;
/**
* $nb_medias
* @var integer
*/
private $nb_medias;
/**
* $excludePosts
* @var object
*/
private $excludePosts = [];
/**
* $excludeUsers
* @var object
*/
private $excludeUsers = [];
/**
* Create a new job instance.
* The unique ID of the job.
*
* @return void
* @return string
*/
public function __construct()
public function uniqueId(): string
{
$this->url = config('services.facebook.graph_url');
$this->version = config('services.facebook.graph_version');
$this->access_token = config('services.facebook.graph_access_token');
$this->fields = config('services.facebook.enpoints.ig_top_media.fields');
$this->ig_hashtag_id = config('services.facebook.enpoints.ig_top_media.ig_hashtag_id');
$this->user_id = config('services.facebook.enpoints.ig_top_media.user_id');
$this->limit = config('services.facebook.enpoints.ig_top_media.limit');
$this->nb_medias = config('services.facebook.enpoints.ig_top_media.nb_medias');
$this->excludePosts = InstaMediaExclude::where('type', ExcludeTypeEnum::post)->get();
$this->excludeUsers = InstaMediaExclude::where('type', ExcludeTypeEnum::user)->get();
return 'InstaGetTopMediasJob';
}
/**
* Execute the job.
*
* @param integer $count
* @param array $params
* @return handle()
* @return void
* @throws \RuntimeException If something goes wrong.
*/
public function handle(int $count = 0, array $params = [])
public function handle(): void
{
try {
$json = $this->callApi($params);
} catch (\Exception $e) {
// TODO : handle exception
}//end try
foreach ($json->data as $ig_media) {
if ($count < $this->nb_medias) {
if ($this->excludePosts->where('value', $ig_media->id)->isNotEmpty()) {
continue;
} else {
$username = $this->getUsername($ig_media->permalink);
if ($this->excludeUsers->where('value', $username)->isNotEmpty()) {
continue;
} else {
$datas = [
'insta_id' => $ig_media->id,
'type' => $ig_media->media_type,
'permalink' => $ig_media->permalink,
'timestamp' => $ig_media->timestamp,
'like_count' => isset($ig_media->like_count) ? $ig_media->like_count : null,
'comments_count' => $ig_media->comments_count,
'username' => $username,
'caption' => $ig_media->caption,
];
$media = new InstaMedia();
$media->fill($datas);
$media->save();
$count++;
}
} //end if
} //end if
} //end foreach
if (
$count < $this->nb_medias && !empty($json->paging->cursors->after) && isset($json->paging->cursors->after)
) {
return $this->handle($count, ['after' => $json->paging->cursors->after]);
Cache::put('top_media_job_fetch_dispatched', true);
Cache::put('top_media_job_fetch_running', true);
DB::transaction(function () {
Cache::put(
'top_media_ajax_fetch',
InstaMedia::fetchInstagramTopMediaList(false)->values()
);
});
Cache::put('top_media_job_fetch_dispatched', false);
} finally {
Cache::put('top_media_job_fetch_running', false);
}
}
/**
* callApi
* @param array $params
* @return mixed
* @throws \GuzzleHttp\Exception\GuzzleException GuzzleException.
*/
private function callApi(array $params = [])
{
$client = new \GuzzleHttp\Client();
try {
$res = $client->request(
'GET',
\sprintf(
"%s/%s/%s/top_media",
$this->url,
$this->version,
$this->ig_hashtag_id
),
[
'query' => array_merge(
[
'user_id' => $this->user_id,
'fields' => $this->fields,
'access_token' => $this->access_token,
'limit' => $this->limit
],
$params
)
]
);
$status = $res->getStatusCode();
if ($status === 200) {
return \json_decode($res->getBody());
} else {
throw new \Exception($res->getBody());
}
} catch (\GuzzleHttp\Exception\ClientException $e) {
throw new \Exception($e);
} //end try
}
/**
* getUsername
* @param string $url
* @return mixed
* @throws \GuzzleHttp\Exception\GuzzleException GuzzleException.
* @throws \RuntimeException RuntimeException.
*/
private function getUsername(string $url)
{
$client = new \GuzzleHttp\Client();
try {
$res = $client->request('GET', $url);
$status = $res->getStatusCode();
if ($status === 200) {
$html = $res->getBody()->getContents();
$dom = new \DOMDocument();
$dom->loadHTML($html);
$scripts = $dom->getElementsByTagName('script');
for ($i = 0; $i < $scripts->length; ++$i) {
$script = $scripts->item($i);
if ($script->getAttribute('type') == 'application/ld+json') {
$entity = json_decode($script->nodeValue, true);
if ($entity['@type'] === 'SocialMediaPosting') {
$username = $entity['author']['identifier']['value'];
return $username;
}
}
}
} else {
throw new \Exception($res->getBody());
}
} catch (\GuzzleHttp\Exception\ClientException $e) {
throw new \Exception($e);
} //end try
}
}

View file

@ -0,0 +1,190 @@
<?php
namespace App\Lib\Helpers;
class ExceptionsHelper
{
/**
* Handle sentry event
*
* @param \Sentry\Event $event
* @return null|\Sentry\Event
*/
public static function sentryEventHandler(\Sentry\Event $event): ?\Sentry\Event
{
// Remove unwanted reports.
$event->setModules([]);
$event->setOsContext(null);
/** @var \Sentry\ExceptionDataBag[] */
$exceptionBag = [];
foreach ($event->getExceptions() as $exceptionDataBag) {
$exceptionBag[] = self::getAnonymizedSentryException($exceptionDataBag);
}
$event->setExceptions($exceptionBag);
return $event;
}
/**
* Anonimize Sentry Exception bag
*
* @param \Sentry\ExceptionDataBag $exceptionDataBag
* @return \Sentry\ExceptionDataBag
*/
private static function getAnonymizedSentryException(
\Sentry\ExceptionDataBag $exceptionDataBag
): \Sentry\ExceptionDataBag {
$exceptionDataBag->setValue(self::getAnonymizedMessage($exceptionDataBag->getValue()));
if ($stackTrace = $exceptionDataBag->getStacktrace()) {
$exceptionDataBag->setStacktrace(self::getAnonymizedSentryStackTrace($stackTrace));
}
return $exceptionDataBag;
}
/**
* Anonymise sentry stacktrace
*
* @param \Sentry\Stacktrace $stackTrace
* @return \Sentry\Stacktrace
*/
private static function getAnonymizedSentryStackTrace(\Sentry\Stacktrace $stackTrace): \Sentry\Stacktrace
{
/** @var \Sentry\Frame[] */
$frames = \array_reverse($stackTrace->getFrames(), true);
$i = \count($frames) - 1;
// * Remove all but keep first (index.php).
while ($i-- > 0) {
$stackTrace->removeFrame(count($stackTrace->getFrames()) - 1);
}
foreach ($frames as $index => $frame) {
$frame->setPostContext([]);
$frame->setPreContext([]);
$frame->setContextLine('');
$frame->setVars(self::getAnonymizedSentryParams($frame->getVars()));
$stackTrace->addFrame($frame);
if ($index === 0) {
$stackTrace->removeFrame(count($stackTrace->getFrames()) - 1);
}
}
return $stackTrace;
}
/**
* Get anonymized sentry params
*
* @param mixed[] $params
* @return string[]
*/
private static function getAnonymizedSentryParams(array $params): array
{
foreach ($params as &$param) {
$type = \strtolower(\gettype($param));
if (\is_string($param)) {
$type = \explode(' ', \trim(strtolower($param)))[0] ?? null;
}
switch (true) {
case $type === 'lambda':
case $type === 'object':
continue 2;
case $type === 'array' and \is_array($param):
$param = sprintf('Array(%d)', \count($param));
continue 2;
default:
if (\is_numeric($param)) {
$param = \ucfirst($type);
continue 2;
}
if (\is_string($param)) {
$param = sprintf('String(%d)', \strlen($param));
continue 2;
}
} //end switch
} //end foreach
return $params;
}
/**
* Anonymize at least Sql queries
*
* @param string $message
* @return string
*/
public static function getAnonymizedMessage(string $message): string
{
$message = \preg_replace('/^(.*)( \(SQL:.*\))$/', '$1', $message) ?? $message;
if ($sqlPos = \strpos($message, 'to use near')) {
$message = \mb_strcut($message, 0, $sqlPos);
}
return $message;
}
/**
* Anonymize stack trace
*
* @param \Throwable $e
* @return string
*/
public static function getAnonymizedStackTrace(\Throwable $e): string
{
$projectPath = \realpath(\sprintf('%s/../', \app_path()));
$traceStack = $e->getTrace();
$traceStr = '';
$maxStackStage = 0;
foreach ($traceStack as $stackStage => $stack) {
$maxStackStage = $stackStage;
$traceStr .= \sprintf(
'#%s %s(%d): ',
$stackStage,
str_replace($projectPath, '', $stack['file'] ?? ''),
$stack['line'] ?? ''
);
if (isset($stack['class'])) {
$traceStr .= "{$stack['class']}->{$stack['function']}(";
if (isset($stack['args'])) {
$traceStr .= self::handleArgs($stack);
}
$traceStr .= ")\n";
} elseif (isset($stack['function'])) {
$traceStr .= "{$stack['function']}(";
if (isset($stack['args'])) {
$traceStr .= self::handleArgs($stack);
}
$traceStr .= ")\n";
}
} //end foreach
$maxStackStage++;
$traceStr .= "#$maxStackStage {main}";
return $traceStr;
}
/**
* Handle Args
*
* @param array $stack
* @return string
*/
public static function handleArgs(array $stack): string
{
$traceArgs = '';
$argsCount = count($stack['args']);
for ($i = 0; $i < $argsCount; $i++) {
$type = \gettype($stack['args'][$i]);
switch ($type) {
case 'object':
$traceArgs .= sprintf('Object(%s)', \get_class($stack['args'][$i]));
break;
case 'array':
$traceArgs .= sprintf('Array(%d)', \count($stack['args'][$i]));
break;
case 'string':
$traceArgs .= sprintf('String(%d)', \strlen($stack['args'][$i]));
break;
default:
$traceArgs .= \gettype($stack['args'][$i]);
}
$traceArgs .= ($i !== ($argsCount - 1) ? ',' : '');
}
return $traceArgs;
}
}

View file

@ -0,0 +1,444 @@
<?php
namespace App\Lib\Helpers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class is used to handle files in LDC Laravel stack
*
* * The storage strategy will be using date.
* The file name may contain sufix timestamp highresolution time (hrtime or microtime) if is duplicate.
* (function() { return time() . ' ' . Str::slug(microtime(true)) . ' ' . hrtime(true); })();
*
* posts/ - The model table name
* 10/ - Year
* 04/ - Month
* 19/ - Day
* 5f/ - First two letters of md5 file
* black-cat-under-red-car.png - Slugified file name
* black-cat-under-red-car-286071690833400.png - Slugified file name that is duplicated
*
* The final path will be storage/modelfiles/posts/10/04/19/5f/black-cat-under-red-car-286071690833400.png
*
* ! No limits of file per folder will be handled.
*
* @author Munsch Jérémy <jeremy.munsch@ldc.fr>
*/
class FileStorageHelper
{
/**
* Destroy the file associate with the attribute.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return void
*/
public static function removeFile(Model $model, string $attribute): void
{
$path = \ltrim(\urldecode($model->{$attribute}), '/');
if (\strpos($path, 'storage') !== 0) {
return;
}
$path = ToolboxHelper::mbReplace('storage/', '', $path);
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
/**
* Destroy the file associate with the attribute old value (before updated to DB).
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @param boolean $onlyIfUpdated
* @return void
*/
public static function removeOldFile(Model $model, string $attribute, bool $onlyIfUpdated = true): void
{
// * Ignore If removal has to be done only if the field has changed and it actually hasnt.
if ($onlyIfUpdated and $model->{$attribute} === $model->getOriginal($attribute)) {
return;
}
$originalPath = \ltrim(\urldecode($model->getOriginal($attribute)), '/');
if (\strpos($originalPath, 'storage') !== 0) {
return;
}
$originalPath = ToolboxHelper::mbReplace('storage/', '', $originalPath);
if (Storage::disk('public')->exists($originalPath)) {
Storage::disk('public')->delete($originalPath);
}
}
/**
* Store a file using optimized storage strategy
*
* You can use filename param to overload file name.
*
* @param \Illuminate\Database\Eloquent\Model $model The model that will hold the picture.
* @param \Illuminate\Http\UploadedFile|\SplFileInfo|string $file The file, will just return itself is string.
* @param boolean $slugify Force filename slugification.
* @param string|null $filename This string may be used to store the file.
* @param boolean $private To store the file in a private space.
* @return string
* @throws \RuntimeException If file copy fails.
*/
public static function storeFile(
Model $model,
$file,
bool $slugify = false,
string $filename = null,
bool $private = false
): string {
$tableName = $model->getTable();
if (\is_object($file) and $file instanceof \Illuminate\Http\UploadedFile) {
if ($private) {
throw new \RuntimeException('Private storage for uploaded files is unhandled');
}
/** @var \Illuminate\Http\UploadedFile $file */
$filename = $filename ?: $file->getClientOriginalName();
$filename = $slugify ? self::slugifyFileName($filename) : $filename;
$newPath = self::prepareMoveFile($tableName, $filename);
$newPath = str_replace(storage_path('app'), '', $newPath);
$file->storePubliclyAs(
pathinfo($newPath, \PATHINFO_DIRNAME),
pathinfo($newPath, \PATHINFO_BASENAME)
);
return 'storage' . str_replace('/public', '', $newPath);
}
if (\is_object($file) and $file instanceof \SplFileInfo) {
/** @var \SplFileInfo $file */
$filename = $filename ?: $file->getBaseName();
$filename = $slugify ? self::slugifyFileName($filename) : $filename;
$newPath = self::prepareMoveFile($tableName, $filename, $private);
if (!File::copy($file->getRealPath(), $newPath)) {
throw new \RuntimeException(sprintf(
'Failed to copy file %s to %s',
$file->getRealPath(),
$newPath
));
}
return $private ?
\ltrim(str_replace(\storage_path('app/private'), '/storage/app/private', $newPath), '/') :
\ltrim(str_replace(\storage_path('app/public'), '/storage', $newPath), '/');
}
return strval($file);
}
/**
* Store all files from string attribute html
* If oldHtmlString is passed, a diff is done with new and old files
* to keep only files that are present in the new html string.
*
* @param \Illuminate\Database\Eloquent\Model $model The model that will hold the picture.
* @param string $htmlString The html string to parse and modify files.
* @param string|null $oldHtmlString The old html string with alreadyParsed
* files using storeModelFilesFromHtml.
* @return string Html with files pathes replaced using storage strategy.
*/
public static function storeModelFilesFromHtml(
Model $model,
string $htmlString,
string $oldHtmlString = null
): string {
\collect(self::onlyUploadedFilesWithFileManager(self::findFilesInHtml($htmlString)))
->each(function (string $path) use ($model, &$htmlString) {
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk('uploads');
$fileInfo = new \SplFileInfo($disk->path(
ToolboxHelper::mbReplace('/uploads/', '', \urldecode($path))
));
$htmlString = ToolboxHelper::mbReplace(
$path,
'/' . self::storeFile($model, $fileInfo, true),
$htmlString
);
});
if ($oldHtmlString) {
self::removeOldModelFilesFromHtml($htmlString, $oldHtmlString);
}
return $htmlString;
}
/**
* Remove all old files from string attribute html that are stored in public that
* are nove present anymore on new html string
*
* @param string $newHtmlString The html string with alreadyParsed files using storeModelFilesFromHtml.
* @param string $oldHtmlString The old html string with alreadyParsed files using storeModelFilesFromHtml.
* @return void
*/
public static function removeOldModelFilesFromHtml(string $newHtmlString, string $oldHtmlString): void
{
self::removeOldFiles(self::findFilesInHtml($oldHtmlString), self::findFilesInHtml($newHtmlString));
}
/**
* Remove all old files that are stored in public that
* are not present anymore on new array files
*
* @param string[] $oldFiles The old array files.
* @param string[] $newFiles The new array files.
* @return void
*/
public static function removeOldFiles(array $oldFiles, array $newFiles): void
{
$oldFiles = \collect(self::onlyUploadedFilesWithFileStorageHelper($oldFiles));
$filesToKeep = \collect(self::onlyUploadedFilesWithFileStorageHelper($newFiles));
$filesToRemove = $oldFiles->diff($filesToKeep);
$filesToRemove->each(function (string $publicStorageFilePath) {
$path = ToolboxHelper::mbReplace('storage/', '', \ltrim(\urldecode($publicStorageFilePath), '/'));
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
});
}
/**
* Remove all files from string attribute html that are stored in public
*
* @param string $htmlString The html string with alreadyParsed files using storeModelFilesFromHtml.
* @return void
*/
public static function removeModelFilesOfHtml(string $htmlString): void
{
$filesToRemove = \collect(self::onlyUploadedFilesWithFileStorageHelper(self::findFilesInHtml($htmlString)));
$filesToRemove->each(function (string $publicStorageFilePath) {
$path = ToolboxHelper::mbReplace('storage/', '', \ltrim(\urldecode($publicStorageFilePath), '/'));
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
});
}
/**
* Find all occurence of files in html
*
* @param string $html
* @return array
*/
public static function findFilesInHtml(string $html): array
{
preg_match_all('/href="(?P<href>.*)"|src="(?P<src>.*)"|url="(?P<url>.*)"/Um', $html, $m, PREG_SET_ORDER, 0);
return collect($m)->pluck('href')->filter()
->concat(collect($m)->pluck('url')->filter())
->concat(collect($m)->pluck('src')->filter())
->all();
}
/**
* Filter array to get only uploaded files that exists
* on disk 'public' (using helper storage strategy)
*
* @param array $urls
* @return array
*/
public static function onlyUploadedFilesWithFileStorageHelper(array $urls): array
{
return \collect($urls)->unique()
// * Filter to get only /uploads and using app url or empty
->filter(function (string $url) {
$path = \parse_url($url, \PHP_URL_PATH);
return \strpos(\ltrim($path, '/'), 'storage/modelfiles') === 0;
})->filter(function (string $url) {
$path = \parse_url($url, \PHP_URL_PATH);
return Storage::disk('public')
->exists(ToolboxHelper::mbReplace('storage/', '', \ltrim(\urldecode($path), '/')));
})->all();
}
/**
* Filter array to get only uploaded files that exists
* on disk 'uploads'
*
* @param array $urls
* @return array
*/
private static function onlyUploadedFilesWithFileManager(array $urls): array
{
return \collect($urls)->unique()
// * Filter to get only /uploads and using app url or empty
->filter(function (string $url) {
$host = \parse_url($url, \PHP_URL_HOST);
$path = \parse_url($url, \PHP_URL_PATH);
return ($host === \parse_url(config('app.url'), PHP_URL_HOST) or \is_null($host)) and
\strpos(\ltrim($path, '/'), 'uploads') === 0;
})->filter(function (string $url) {
$path = \parse_url($url, \PHP_URL_PATH);
return Storage::disk('uploads')
->exists(ToolboxHelper::mbReplace('uploads/', '', \ltrim(\urldecode($path), '/')));
})->all();
}
/**
* Prepare storage path and return
* the file full path
*
* @param string $tableName Model table name.
* @param string $filename The basename for the file.
* @param boolean $private May the file be stored in a private dir.
* @return string
*/
private static function prepareMoveFile(string $tableName, string $filename, bool $private = false): string
{
// * Security filter + beautifier
$filename = self::sanitizeFileName($filename);
// * Security filter
$filename = \filter_var($filename, \FILTER_SANITIZE_STRING);
// * Slugify filename
$ext = \pathinfo($filename, \PATHINFO_EXTENSION);
$filename = Str::slug(\pathinfo($filename, \PATHINFO_FILENAME)) .
(\strlen($ext) ? ".{$ext}" : '');
$folderPath = self::getStoragePath(
$private ? 'app/private/modelfiles' : 'app/public/modelfiles',
$tableName,
$filename,
$private
);
$filename = self::getFileUniqueName($folderPath, $filename);
return "{$folderPath}/{$filename}";
}
/**
* Create sub folders to store files using pattern
* storage/modelfiles/posts/10/04/19/5f
* year/month/day/md5 first 2 letters
*
* @param string $storage_dir
* @param string $table
* @param string $filename
* @return string
*/
private static function getStoragePath(string $storage_dir, string $table, string $filename): string
{
$filenameHash = \md5($filename);
$pathFolder = \storage_path(sprintf(
'%s/%s/%s/%s/%s/%s',
$storage_dir,
$table,
gmdate('y'),
gmdate('m'),
gmdate('d'),
substr($filenameHash, 0, 2)
));
if (!File::exists($pathFolder)) {
File::makeDirectory($pathFolder, 0755, true);
}
return $pathFolder;
}
/**
* Get unique filename if the path is already occupied
*
* @param string $folderPath
* @param string $filename
* @return string
* @throws \RuntimeException If a unique filename cannot be obtained.
*/
private static function getFileUniqueName(string $folderPath, string $filename): string
{
$attempts = 0;
$maxAttempts = 20;
while (File::exists("{$folderPath}/{$filename}")) {
if ($attempts++ >= $maxAttempts) {
throw new \RuntimeException("Failed to has a unique filename for {$folderPath}/{$filename}");
}
$filename = pathinfo($filename, \PATHINFO_FILENAME) .
\uniqid('-') . '.' .
pathinfo($filename, \PATHINFO_EXTENSION);
}
return $filename;
}
/**
* Slugify the filename
*
* @param string $filename
* @return string
*/
public static function slugifyFileName(string $filename): string
{
return Str::slug(pathinfo($filename, \PATHINFO_FILENAME)) . '.' . pathinfo($filename, \PATHINFO_EXTENSION);
}
/**
* Sanitize File name for uploaded files
*
* @param string $filename The original uploaded client file name.
* @param boolean $beautify Beautify the filename.
* @return string The sanitized file name.
*/
public static function sanitizeFileName(string $filename, bool $beautify = true): string
{
// * Sanitize filename
// phpcs:disable Generic.Files.LineLength.TooLong
$filename = preg_replace(
'~
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
[\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
[\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
[#\[\]@!$&\'()+,;=]| # URI reserved https://tools.ietf.org/html/rfc3986#section-2.2
[{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
~x',
'-',
$filename
);
// phpcs:enable
// * Avoids '.' '..' or '.hiddenFiles' .
$filename = Str::of($filename)->ltrim('.-');
// * Optional beautification
if ($beautify) {
$filename = static::beautifyFilename($filename);
}
// * Maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
/** @var string */
$extension = pathinfo($filename, \PATHINFO_EXTENSION);
/** @var string */
$filename = pathinfo($filename, \PATHINFO_FILENAME);
$encoding = mb_detect_encoding($filename);
$encoding = $encoding ? $encoding : null;
return \mb_strcut(
$filename,
0,
255 - (!empty($extension) ? strlen($extension) + 1 : 0),
$encoding
) . (!empty($extension) ? ".$extension" : '');
}
/**
* Beautify a filename
*
* @param string $filename
* @return string
*/
private static function beautifyFilename(string $filename): string
{
// Reduce consecutive characters.
$filename = preg_replace([
// * "file name.zip" becomes 'file-name.zip'
'/ +/',
// * "file___name.zip" becomes 'file-name.zip'
'/_+/',
// * "file---name.zip" becomes 'file-name.zip'
'/-+/'
], '-', $filename);
$filename = preg_replace([
// * "file--.--.-.--name.zip" becomes "file.name.zip"
'/-*\.-*/',
// * "file...name..zip" becomes "file.name.zip"
'/\.{2,}/'
], '.', $filename);
// Lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625 .
$filename = mb_strtolower($filename, mb_detect_encoding($filename));
// * ".file-name.-" becomes "file-name"
$filename = trim($filename, '.-');
return $filename;
}
}

View file

@ -0,0 +1,242 @@
<?php
namespace App\Lib\Helpers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ToolboxHelper
{
/**
* Strip scheme (https) on oembed urls
* Only embed allowed are for MP4 self hosted videos right now
*
* https is force by ckeditor MediaElements which natively supports
* only cloud hosted medias (youtube, spotify etc..)
*
* @param string $input
* @return string
*/
public static function stripCkEditorSchemesOnMediaInputs(string $input): string
{
$input = \preg_replace('/(?<=<oembed url=")(https:\/\/)(?=.*?">)/m', '', $input);
return $input;
}
/**
* Assert That no Back Office url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalBoUrls(string $field, string $text): string
{
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo'
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => ':attribute : Il est interdit de coller une url du back office dans ce champ'
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Assert That only external url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalUrls(string $field, string $text): string
{
self::assertNoInternalBoUrls($field, $text);
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
$routes->match($request)
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => sprintf(
':attribute : Il est interdit de coller une url de %s dans ce champ',
config('app.name')
)
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Mutltibyte string replace
*
* @param array|string $search
* @param array|string $replace
* @param array|string $subject
* @param integer $count
* @return array|string
*/
public static function mbReplace(
array|string $search,
array|string $replace,
array|string $subject,
int &$count = 0
): array|string {
if (!is_array($search) && is_array($replace)) {
return false;
}
if (is_array($subject)) {
// Call mb_replace for each single string in $subject .
foreach ($subject as &$string) {
$string = &self::mbReplace($search, $replace, $string, $count);
}
} elseif (is_array($search)) {
if (!is_array($replace)) {
foreach ($search as &$string) {
$subject = self::mbReplace($string, $replace, $subject, $count);
}
} else {
$n = max(count($search), count($replace));
while ($n--) {
$subject = self::mbReplace(current($search), current($replace), $subject, $count);
next($search);
next($replace);
}
}
} else {
$parts = mb_split(preg_quote($search), $subject);
$count = count($parts) - 1;
$subject = implode($replace, $parts);
} //end if
return $subject;
}
/**
* Asserts that the field is unique
*
* @param string $table
* @param string $field
* @param string|integer|float|boolean|null|array $value
* @param integer|null $id
* @param callable|null $query
* @return void
* @throws \Illuminate\Validation\ValidationException If the field is not unique.
*/
public static function assertFieldIsUnique(
string $table,
string $field,
string|int|float|bool|null|array $value,
?int $id = null,
?callable $query = null
) {
if (!$query) {
Validator::make([$field => $value], [
self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id),
], [
self::mbReplace('.', '\.', "{$field}.unique")
=> trans('La valeur :value pour le champ :attribute est déjà utilisé', [
'attribute' => $field,
'value' => $value
])
])->validate();
return;
}
Validator::make([$field => $value], [
$field => Rule::unique($table)->where($query)->ignore($id),
], [
$field . '.unique' => trans('La valeur pour le champ :attribute est déjà utilisé', [
'attribute' => $field
])
])->validate();
}
/**
* Convert deeply an object to array
*
* @param array|object $input
* @return array
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function objectToArray($input): array
{
// Only process if it's an object or array being passed to the function.
if (is_object($input) or is_array($input)) {
$ret = (array)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::objectToArray($item) : $item;
}
return $ret;
} else {
throw new \RuntimeException('cannot handle $input of type ' . \gettype($input));
}
}
/**
* Convert deeply an array to object
*
* @param array|object $input
* @return object
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function arrayToObject($input): object
{
// Only process if it's an object or array being passed to the function.
if (is_object($input) or is_array($input)) {
$ret = (object)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::arrayToObject($item) : $item;
}
return $ret;
} else {
throw new \RuntimeException('cannot handle $input of type ' . \gettype($input));
}
}
}

View file

@ -0,0 +1,339 @@
<?php
namespace App\Lib\Instagram;
use App\Exceptions\InstaApiLimitReachedException as InstaApiException;
use App\Lib\Instagram\Objects\TopMedia;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\UriInterface;
/**
* Fetch Instagram Top Media List
*
* @package App\Lib
*/
class ApiFetcher
{
/**
* Top media fields to Fetch
*
* @var string[]
*/
private $topMediaFields = [
'id', 'media_type', 'like_count',
'comments_count', 'permalink',
'timestamp', 'caption'
];
/**
* Hashtag fields to Fetch
*
* @var string[]
*/
private $hashtagFields = [
'id', 'name'
];
/**
* InstagramTopMediaList
*
* @param string $url The api endpoint url.
* @param string $version The api endpoint version.
* @param string $access_token The api endpoint access token.
* @return void
*/
public function __construct(
private string $url,
private string $version,
private string $access_token,
) {
$this->url = $url;
$this->version = $version;
$this->access_token = $access_token;
}
/**
* Fetch the top media list
*
* @phpcs:disable Generic.Files.LineLength.TooLong
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*
* @param integer $hashtag_id The IG hashtag Id we whould target.
* @param integer $user_id The IG user id we want to fetch.
* @param integer $media_count The IG media amount to fetch.
* @param \Illuminate\Database\Eloquent\Collection<\App\Models\InstaMediaExclusion> $excludedPosts The IG user id we want to fetch.
* @param \Illuminate\Database\Eloquent\Collection<\App\Models\InstaMediaExclusion> $excludedUsers The IG user id we want to fetch.
* @return \Illuminate\Support\Collection<array-key,\App\Lib\Instagram\Objects\TopMedia>
* @throws \RuntimeException If a call error or a response parsing error occurs.
* @throws \App\Exceptions\InstaApiException If the api denies access for some reason.
* @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
*/
public function fetchHashtagTopMediaList(
int $hashtag_id,
int $user_id,
int $media_count,
Collection $excludedPosts,
Collection $excludedUsers
): SupportCollection {
// phpcs:enable
// * Limiter le nombre de calls pour sortir la liste.
$max_total_calls = 150;
// * Memoriser le nombre de call pour 1H.
$total_calls = Cache::remember('top_media_job_fetch_total_calls', 60 * 60, fn () => 0);
if ($max_total_calls <= $total_calls) {
throw new InstaApiException('Call LIMIT Reached');
}
// * Limite en dur pour optimiser le nombre de calls.
$media_per_page = 50;
$count = 0;
$path = \sprintf('%s/top_media', $hashtag_id);
$parameters = [
'user_id' => $user_id,
'fields' => \implode(',', $this->topMediaFields),
'limit' => $media_per_page
];
$outCollection = collect();
// * Recursive Closure to fetch paginated ig media lists.
$fetchHashtagTopMediaListClosure = null;
$fetchHashtagTopMediaListClosure = function (
int &$count,
) use (
$excludedPosts,
$excludedUsers,
$path,
$parameters,
$media_count,
&$outCollection,
&$fetchHashtagTopMediaListClosure,
&$total_calls,
$max_total_calls
) {
$total_calls++;
$response = $this->callApi('GET', $path, $parameters);
$json = $response->object();
$fetched = \collect($json->data)->map(function (
object $ig_media
) use (
&$count,
&$outCollection,
$media_count,
$excludedPosts,
$excludedUsers
) {
// * Ignore if enough media.
if ($count >= $media_count) {
return null;
}
// * Ignore if excluded Post.
if ($excludedPosts->where('value', $ig_media->id)->isNotEmpty()) {
return null;
}
// * Ignore already included post
if ($outCollection->where('insta_id', $ig_media->id)->isNotEmpty()) {
return null;
}
// * Ignore if excluded User.
$username = $this->fetchUsernameFromPostPermalink($ig_media->permalink);
if (!$username) {
throw new \RuntimeException('Failed to fetch username from id media permalink scrapping');
}
if ($excludedUsers->where('value', $username)->isNotEmpty()) {
return null;
}
$count++;
$topMedia = new TopMedia();
$topMedia->setData($ig_media);
$topMedia->setUserName($username);
return $topMedia;
});
$outCollection = $outCollection->concat($fetched->filter());
// * Manual pagination using IG cursors.
if (
$max_total_calls < $total_calls &&
$count < $media_count &&
!empty($json->paging->cursors->after) &&
isset($json->paging->cursors->after)
) {
$outCollection = $outCollection->concat($fetchHashtagTopMediaListClosure(
$count,
\array_merge(
['after' => $json->paging->cursors->after],
$parameters
)
));
}
};
$fetchHashtagTopMediaListClosure($count, $parameters);
Cache::put('top_media_job_fetch_total_calls', $total_calls);
return $outCollection;
}
/**
* Fetch tag name using its id
*
* @param integer $hashtag_id
* @return string
* @throws \RuntimeException If a call error or a response parsing error occurs.
* @throws \App\Exceptions\InstaApiException If the api denies access for some reason.
*/
public function fetchTagName(int $hashtag_id): string
{
$response = $this->callApi('GET', $hashtag_id, [
'fields' => \implode(',', $this->hashtagFields),
]);
return \strval($response->json('name'));
}
/**
* Try to fetch media pictures from a permalink using og data.
*
* @param string $url
* @return array
* @throws \RuntimeException If a call error or a response parsing error occurs.
*/
public function fetchMediaPicturesFromPostPermalink(string $url): array
{
try {
$response = $this->httpCall('GET', $url);
$html = $response->body();
$dom = new \DOMDocument();
$dom->loadHTML($html);
$scripts = $dom->getElementsByTagName('script');
return collect($scripts)->filter()->map(function (\DOMElement $script) {
if ($script->getAttribute('type') != 'application/ld+json') {
return null;
}
$entity = json_decode($script->nodeValue, true);
if ($entity['@type'] !== 'SocialMediaPosting') {
return null;
}
return \collect($entity['image'])->map(function (array $picture) {
return $picture['url'];
})->all();
})->filter(function ($el) {
return !empty($el);
})->first() ?? [];
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage(), $e->getCode(), $e);
} //end try
}
/**
* Try to fetch user name from a permalink using og data.
*
* @param string $url
* @return null|string
* @throws \RuntimeException If a call error or a response parsing error occurs.
*/
public function fetchUsernameFromPostPermalink(string $url): ?string
{
try {
$response = $this->httpCall('GET', $url);
$html = $response->body();
$dom = new \DOMDocument();
$dom->loadHTML($html);
$scripts = $dom->getElementsByTagName('script');
return collect($scripts)->filter()->map(function (\DOMElement $script) {
if ($script->getAttribute('type') != 'application/ld+json') {
return null;
}
$entity = json_decode($script->nodeValue, true);
if ($entity['@type'] !== 'SocialMediaPosting') {
return null;
}
return \strval($entity['author']['identifier']['value']);
})->filter(function ($el) {
return !empty($el);
})->first();
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage(), $e->getCode(), $e);
} //end try
}
/**
* callApi
*
* @param string $method The HTTP VERB to use.
* @param string $path The path api to call.
* @param array $params Api call parameters to pass on.
* @return \Illuminate\Http\Client\Response
* @throws \RuntimeException Error on call.
* @throws \App\Exceptions\InstaApiException If the api denies access for some reason.
*/
private function callApi(
string $method,
string $path,
array $params = []
): Response {
return $this->httpCall(
$method,
\sprintf(
"%s/%s/%s",
$this->url,
$this->version,
$path
),
array_merge(
[
'access_token' => $this->access_token
],
$params
)
);
}
/**
* Call url and fetch result in array.
*
* @param string $method The HTTP VERB to use (GET, POST).
* @param string|\Psr\Http\Message\UriInterface $uri The uri to call.
* @param array $parameters The call parameters to pass on.
* @return \Illuminate\Http\Client\Response
* @throws \Error If the request method is not supported.
* @throws \RuntimeException If something goes wrong.
* @throws \App\Exceptions\InstaApiException If the api denies access for some reason.
*/
private function httpCall(
string $method,
string|UriInterface $uri = '',
array $parameters = []
): Response {
try {
if (!\in_array(\strtoupper($method), ['GET', 'POST'])) {
throw new \Error("Unsupported method {$method}");
}
/** @var \Illuminate\Http\Client\Response */
$response = Http::connectTimeout(\config('app.debug') ? 3 : 30)
->retry(2, 100)
->{strtolower($method)}($uri, $parameters);
// * Throw an exception if an error occured.
$response->throw();
return $response;
} catch (HttpClientException $e) {
/** @var \Illuminate\Http\Client\Response $response */
if ($e instanceof \Illuminate\Http\Client\RequestException and $e->getCode() === 403) {
$response = $e->response;
$message = $response->json('error.message');
$code = $response->json('error.code');
throw new InstaApiException($message, $code, $e);
}
throw new \RuntimeException($e);
} //end try
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Lib\Instagram\Objects;
use Illuminate\Support\Carbon;
/**
* Top Media object
*
* @property integer $insta_id A unique instagram id that can be used for api calls.
* @property string $type The instagram object type, commonly IMAGE or CAROUSEL_ALBUM.
* @property string $username The instagram user name, scrapped using OG description.
* @property string $caption The object text content.
* @property string $permalink The uri where is stored the Instagram object.
* @property \Carbon\Carbon $timestamp UTC timestamp from instagram api.
* @property integer $comments_count The number of comments.
* @property null|integer $like_count The number of likes.
*
* @package App\Lib\Instagram\Objects
*/
class TopMedia extends \stdClass
{
/**
* Set this object data from Ig api data
*
* @param object $ig_media
* @return void
*/
public function setData(object $ig_media): void
{
$this->insta_id = \intval($ig_media->id);
$this->type = $ig_media->media_type;
$this->permalink = $ig_media->permalink;
$this->timestamp = Carbon::parse($ig_media->timestamp);
$this->comments_count = \intval($ig_media->comments_count);
$this->like_count = \optional($ig_media)->like_count ? \intval($ig_media->like_count) : null;
$this->caption = $ig_media->caption;
}
/**
* Set this object username
*
* @param string $username
* @return void
*/
public function setUserName(string $username): void
{
$this->username = $username;
}
}

View file

@ -2,31 +2,157 @@
namespace App\Models;
use App\Enums\ExcludeTypeEnum;
use App\Exceptions\InstaApiLimitReachedException;
use App\Lib\Instagram\Objects\TopMedia;
use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
/**
* @mixin \App\Lib\Instagram\Objects\TopMedia
* @package App\Models
*/
class InstaMedia extends Model
{
use HasFactory;
/**
* $table
* @var string
*/
protected $table = "insta_medias";
/**
* $fillable
*
* @var string[]
*/
protected $fillable = [
'insta_id',
'type',
'username',
'caption',
'permalink',
'timestamp',
'like_count',
'comments_count',
'username',
'caption'
'like_count'
];
/**
* The attributes that should be cast.
*
* @var string[]
*/
protected $casts = [
'caption' => AsStringable::class,
'permalink' => AsStringable::class,
'timestamp' => 'immutable_datetime'
];
// * METHODS
/**
* Fetch Instagram Top Media List
*
* @param boolean $asModel Wether to get a model list or a api response object list.
* @return \Illuminate\Support\Collection<\App\Lib\Instagram\Objects\TopMedia|\App\Models\InstaMedia>
* @throws \Illuminate\Validation\ValidationException If an api call 403 error occurs.
*/
public static function fetchInstagramTopMediaList(bool $asModel = true): Collection
{
try {
/**
* @var integer $hashtag_id
* @var integer $user_id
* @var integer $media_count
* @var array<array-key, string> $fields
*/
\extract(self::fetchConfigParameters());
/** @var \Illuminate\Database\Eloquent\Collection<array-key,\App\Models\InstaMediaExclusion> */
$excludePosts = InstaMediaExclusion::query()->where('type', ExcludeTypeEnum::post)->get();
/** @var \Illuminate\Database\Eloquent\Collection<array-key,\App\Models\InstaMediaExclusion> */
$excludeUsers = InstaMediaExclusion::query()->where('type', ExcludeTypeEnum::user)->get();
/** @var \App\Lib\Instagram\ApiFetcher */
$igFetcherClient = \app('Instagram');
$list = $igFetcherClient->fetchHashtagTopMediaList(
$hashtag_id,
$user_id,
$media_count,
$excludePosts,
$excludeUsers
);
if ($asModel) {
return $list->map(function (TopMedia $ig_media) {
return (new InstaMedia((array)$ig_media));
});
}
return $list;
} catch (InstaApiLimitReachedException $e) {
report($e);
throw ValidationException::withMessages([
'error' => 'Api error ' . $e->getMessage()
]);
} catch (\RuntimeException $e) {
report($e);
throw ValidationException::withMessages([
'error' => $e->getMessage()
]);
}//end try
}
/**
* Fetch Media picture using ig media hash
*
* @param string $ig_media_hash
* @return array
* @throws \RuntimeException If a call error or a response parsing error occurs.
*/
public static function fetchInstagramMediaPictures(string $ig_media_hash): array
{
/** @var \App\Lib\Instagram\ApiFetcher */
$igFetcherClient = \app('Instagram');
return $igFetcherClient->fetchMediaPicturesFromPostPermalink("https://www.instagram.com/p/{$ig_media_hash}/");
}
/**
* Fetch and check config elements
*
* @return array 'hashtag_id', 'user_id', 'media_count', 'fields'
* @throws \RuntimeException If config is not valid.
*/
private static function fetchConfigParameters(): array
{
$hashtag_id = config('services.facebook.enpoints.ig_top_media.hashtag_id');
$user_id = config('services.facebook.enpoints.ig_top_media.user_id');
$media_count = config('services.facebook.enpoints.ig_top_media.media_count');
if (empty($hashtag_id)) {
throw new \RuntimeException(
'Missing services.facebook.enpoints.ig_top_media.hashtag_id,
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_HASHTAGID'
);
}
if (empty($user_id)) {
throw new \RuntimeException(
'Missing services.facebook.enpoints.ig_top_media.user_id,
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_USERID'
);
}
if (empty($media_count)) {
throw new \RuntimeException(
'Missing services.facebook.enpoints.ig_top_media.media_count,
FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIA'
);
}
$hashtag_id = \intval($hashtag_id);
$user_id = \intval($user_id);
$media_count = \intval($media_count);
return \compact(
'hashtag_id',
'user_id',
'media_count'
);
}
}

View file

@ -6,10 +6,20 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Enums\ExcludeTypeEnum;
class InstaMediaExclude extends Model
class InstaMediaExclusion extends Model
{
use HasFactory;
/**
* $fillable
*
* @var string[]
*/
protected $fillable = [
'type',
'value'
];
/**
* The attributes that should be cast.
*

View file

@ -2,10 +2,10 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
class User extends Authenticatable
{
@ -18,7 +18,8 @@ class User extends Authenticatable
* @var array<int, string>
*/
protected $fillable = [
'name',
'firstname',
'lastname',
'email',
'password',
];
@ -41,4 +42,24 @@ class User extends Authenticatable
protected $casts = [
'email_verified_at' => 'datetime',
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
// * EVENT CLOSURES.
static::creating(function (self $model) {
if (Hash::needsRehash($model->password)) {
$model->password = Hash::make($model->password);
}
});
static::updating(function (self $model) {
if (Hash::needsRehash($model->password)) {
$model->password = Hash::make($model->password);
}
});
}
}

View file

@ -2,6 +2,13 @@
namespace App\Providers;
use App\Lib\Instagram\ApiFetcher;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -22,6 +29,106 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
url()->forceScheme("https");
// * HTTPS FIX
URL::forceScheme('https');
// * HA PROXY FIX
App::singleton('clientIp', function () {
if (config('app.env') === 'local') {
return request()->getClientIp();
} else {
return request()->server('HTTP_X_FORWARDED_FOR') ??
request()->getClientIp();
}
});
// * LOCAL FORCE eg: for Carbon
setlocale(LC_ALL, \sprintf(
'%s_%s.UTF-8',
config('app.locale'),
\strtoupper(config('app.locale'))
));
Carbon::setLocale(config('app.locale'));
$this->sharetoView();
// * Instagram Singleton
App::singleton('Instagram', function () {
$url = config('services.facebook.graph_url');
$version = config('services.facebook.graph_version');
$access_token = config('services.facebook.graph_access_token');
if (empty($url) or !is_string($url)) {
throw new \RuntimeException(
'Missing services.facebook.graph_url, FACEBOOK_GRAPH_URL'
);
}
if (empty($version) or !is_string($version)) {
throw new \RuntimeException(
'Missing services.facebook.graph_version, FACEBOOK_GRAPH_ACCESS_TOKEN'
);
}
if (empty($access_token) or !is_string($access_token)) {
throw new \RuntimeException(
'Missing services.facebook.graph_access_token, FACEBOOK_GRAPH_VERSION'
);
}
return new ApiFetcher(
$url,
$version,
$access_token
);
});
}
/**
* Share Vars to view
*
* @return void
*/
private function sharetoView()
{
if (app()->runningInConsole()) {
return;
}
// * VITE ALIASES
Vite::macro('image', function (string $asset) {
/** @var \Illuminate\Foundation\Vite $self */
$self = $this;
$self->asset("resources/assets/images/{$asset}");
});
// * FORCE BOOTSTRAP PAGINATOR (or custom if in front)
// Early boot, request wont be filled as expected.
if (collect(explode('/', \request()->getPathInfo()))->get(1) === 'bo') {
Paginator::useBootstrap();
} else {
Paginator::useBootstrap();
}
// * Hashtag Fetch
App::singleton('topMediaHashTag', fn () => Cache::remember(
'top_media_hashtag',
// * Remember 7 days.
7 * 24 * 60 * 60,
function () {
/** @var string|null */
$hashtag_id = config('services.facebook.enpoints.ig_top_media.hashtag_id');
$hashtag_id = !empty($hashtag_id) ? \intval($hashtag_id) : $hashtag_id;
if (empty($hashtag_id) or !\is_int($hashtag_id)) {
return 'Non configuré';
}
/** @var \App\Lib\Instagram\ApiFetcher */
$igFetcherClient = \app('Instagram');
try {
return $igFetcherClient->fetchTagName($hashtag_id);
} catch (\RuntimeException $e) {
report($e);
return 'Erreur IG api call';
}
}
));
}
}

View file

@ -18,8 +18,7 @@ class AuthServiceProvider extends ServiceProvider
*
* @return void
*/
public function boot()
public function boot(): void
{
$this->registerPolicies();
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View file

@ -24,7 +24,7 @@ class EventServiceProvider extends ServiceProvider
*
* @return void
*/
public function boot()
public function boot(): void
{
}
@ -33,7 +33,7 @@ class EventServiceProvider extends ServiceProvider
*
* @return boolean
*/
public function shouldDiscoverEvents()
public function shouldDiscoverEvents(): bool
{
return false;
}

View file

@ -11,7 +11,7 @@ use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to the "home" route for your application.
* The path to your application's "home" route.
*
* Typically, users are redirected here after authentication.
*
@ -24,17 +24,20 @@ class RouteServiceProvider extends ServiceProvider
*
* @return void
*/
public function boot()
public function boot(): void
{
$this->configureRateLimiting();
$this->cspReportRoute();
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web-bo.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
->namespace($this->namespace)
->group(base_path('routes/web-fo.php'));
});
}
@ -43,10 +46,52 @@ class RouteServiceProvider extends ServiceProvider
*
* @return void
*/
protected function configureRateLimiting()
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
return Limit::perMinute(150)->by($request->user()?->id ?: app('clientIp'));
});
RateLimiter::for('web', function (Request $request) {
return Limit::perMinute(150)->by($request->user()?->id ?: app('clientIp'));
});
RateLimiter::for('credentials', function (Request $request) {
return Limit::perMinute(30)->by($request->user()?->id ?: app('clientIp'));
});
}
/**
* * Register CSP report URI
*
* Put this function inside RouteServiceProvider,
* and call it in RouteServiceProvider::boot
*
* @return void
*/
private function cspReportRoute()
{
// * CSP REPORT URI, Please add /csp-report to VerifyCsrfToken $except middleware
\Illuminate\Support\Facades\Route::post('/csp-report', function () {
$isCspRequest = request()->header('Content-Type') === 'application/csp-report';
if (!$isCspRequest or !request()->json('csp-report') or !is_array(request()->json('csp-report'))) {
abort(401);
}
// * Flatten if needed
$cspReport = collect(request()->json('csp-report'))
->flatMap(function ($v, $k) {
return [$k => is_array($v) ? collect($v)->flatten()->implode(', ') : $v];
})->all();
// * Filter array keys
$cspReport = collect(array_intersect_key($cspReport, [
'blocked-uri' => '', 'document-uri' => '', 'original-policy' => '',
'referrer' => '', 'violated-directive' => ''
]));
\Illuminate\Support\Facades\Log::warning(sprintf(
"CSP REPORT for %s : \n %s",
$cspReport->get('document-uri'),
$cspReport->implode("\n")
));
})->middleware('throttle:5,2')
->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class)
->name('cspReportUri');
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Providers;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Finder\SplFileInfo;
class TranslationServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot(): void
{
foreach ($this->collectLocalesStrings() as $locale) {
// * Suported locales
Cache::rememberForever(sprintf('translations.%s', $locale), function () use ($locale) {
$translations = [
'php' => $this->phpTranslations($locale),
'json' => $this->jsonTranslations($locale),
];
return $translations;
});
}
}
/**
* Gather provided langs checking from files
*
* @return \Illuminate\Support\Collection<string, string>
*/
private function collectLocalesStrings(): Collection
{
return collect(File::allFiles(resource_path('lang/')))->flatMap(function (SplFileInfo $file) {
if ($file->getRelativePath() and \strpos($file->getRelativePath(), 'vendor') !== 0) {
return [$file->getRelativePath() => ''];
}
})->keys();
}
/**
* Gather all php translations
*
* @param string $locale
* @return array<string, string>
*/
private function phpTranslations(string $locale): array
{
$path = resource_path("lang/$locale");
return collect(File::allFiles($path))->flatMap(function ($file) use ($locale) {
$translation = $file->getBasename('.php');
return [$translation => trans($translation, [], $locale)];
})->all();
}
/**
* Gather json translations
*
* @param string $locale
* @return array<string, string>
*/
private function jsonTranslations(string $locale): array
{
$path = resource_path("lang/$locale.json");
if (is_string($path) && is_readable($path)) {
return json_decode(file_get_contents($path), true) ?? [];
}
return [];
}
}

View file

View file

View file

View file

@ -2,23 +2,33 @@
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"require": {
"php": "^8.0.2",
"php": "^8.1",
"diglactic/laravel-breadcrumbs": "^8.0",
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^9.19",
"laravel/tinker": "^2.7"
"laravel/framework": "^10.0",
"laravel/tinker": "^2.8",
"laravel/ui": "^4.2",
"mews/purifier": "^3.4",
"sentry/sentry-laravel": "*"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"kitloong/laravel-migrations-generator": "^6.8",
"laravel-lang/common": "^3.1",
"laravel/pint": "^1.0",
"laravel/sail": "^1.0.1",
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^6.1",
"phpunit/phpunit": "^9.5.10",
"slevomat/coding-standard": "^8.8",
"spatie/laravel-ignition": "^1.0"
"nunomaduro/collision": "^7.0",
"phpunit/phpunit": "^10.0",
"slevomat/coding-standard": "^8.10",
"spatie/laravel-ignition": "^2.0",
"thedoctor0/laravel-factory-generator": "^1.2"
},
"autoload": {
"psr-4": {

4310
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -139,7 +139,7 @@ return [
*/
'maintenance' => [
'driver' => 'file',
'driver' => 'file'
],
/*
@ -194,6 +194,7 @@ return [
App\Providers\AuthServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\TranslationServiceProvider::class
],

View file

@ -14,7 +14,7 @@ return [
*/
'defaults' => [
'guard' => 'web',
'guard' => 'frontend',
'passwords' => 'users',
],
@ -36,10 +36,16 @@ return [
*/
'guards' => [
'web' => [
'backend' => [
'driver' => 'session',
'provider' => 'users',
],
'frontend' => [
'driver' => 'session',
// ! Remplacer "users" par le model authenticable voulu
'provider' => 'users',
],
],
/*
@ -75,16 +81,20 @@ return [
| than one user table or model in the application and you want to have
| separate password reset settings based on the specific user types.
|
| The expire time is the number of minutes that each reset token will be
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_resets',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],

78
config/breadcrumbs.php Normal file
View file

@ -0,0 +1,78 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| View Name
|--------------------------------------------------------------------------
|
| Choose a view to display when Breadcrumbs::render() is called.
| Built in templates are:
|
| - 'breadcrumbs::bootstrap5' - Bootstrap 5
| - 'breadcrumbs::bootstrap4' - Bootstrap 4
| - 'breadcrumbs::bulma' - Bulma
| - 'breadcrumbs::foundation6' - Foundation 6
| - 'breadcrumbs::json-ld' - JSON-LD Structured Data
| - 'breadcrumbs::materialize' - Materialize
| - 'breadcrumbs::tailwind' - Tailwind CSS
| - 'breadcrumbs::uikit' - UIkit
|
| Or a custom view, e.g. '_partials/breadcrumbs'.
|
*/
'view' => 'breadcrumbs/breadrcumb-layout',
/*
|--------------------------------------------------------------------------
| Breadcrumbs File(s)
|--------------------------------------------------------------------------
|
| The file(s) where breadcrumbs are defined. e.g.
|
| - base_path('routes/breadcrumbs.php')
| - glob(base_path('breadcrumbs/*.php'))
|
*/
'files' => [
base_path('routes/breadcrumbs-bo.php'),
base_path('routes/breadcrumbs-fo.php'),
],
/*
|--------------------------------------------------------------------------
| Exceptions
|--------------------------------------------------------------------------
|
| Determine when to throw an exception.
|
*/
// * When route-bound breadcrumbs are used but the current route doesn't have a name (UnnamedRouteException)
'unnamed-route-exception' => true,
// * When route-bound breadcrumbs are used and the matching breadcrumb doesn't exist (InvalidBreadcrumbException)
'missing-route-bound-breadcrumb-exception' => true,
// * When a named breadcrumb is used but doesn't exist (InvalidBreadcrumbException)
'invalid-named-breadcrumb-exception' => true,
/*
|--------------------------------------------------------------------------
| Classes
|--------------------------------------------------------------------------
|
| Subclass the default classes for more advanced customisations.
|
*/
// * Manager
'manager-class' => Diglactic\Breadcrumbs\Manager::class,
// * Generator
'generator-class' => Diglactic\Breadcrumbs\Generator::class,
];

View file

@ -43,7 +43,7 @@ return [
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html.
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html .
],
],

View file

@ -62,7 +62,7 @@ return [
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,.
// Memcached::OPT_CONNECT_TIMEOUT => 2000, .
],
'servers' => [
[

View file

@ -15,7 +15,7 @@ return [
|
*/
'paths' => ['api/*'],
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],

View file

@ -3,6 +3,7 @@
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
@ -61,6 +62,7 @@ return [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
@ -68,6 +70,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [
@ -76,6 +79,7 @@ return [
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
@ -87,6 +91,7 @@ return [
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
@ -97,16 +102,20 @@ return [
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [

View file

@ -28,7 +28,7 @@ return [
| sending an e-mail. You will specify which one you are using for your
| mailers below. You are free to add additional mailers as required.
|
| Supported: "smtp", "sendmail", "mailgun", "ses",
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "log", "array", "failover"
|
*/
@ -50,11 +50,11 @@ return [
],
'mailgun' => [
'transport' => 'mailgun',
'transport' => 'mailgun'
],
'postmark' => [
'transport' => 'postmark',
'transport' => 'postmark'
],
'sendmail' => [

121
config/purifier.php Normal file
View file

@ -0,0 +1,121 @@
<?php
/**
* Ok, glad you are here
* first we get a config instance, and set the settings
* $config = HTMLPurifier_Config::createDefault();
* $config->set('Core.Encoding', $this->config->get('purifier.encoding'));
* $config->set('Cache.SerializerPath', $this->config->get('purifier.cachePath'));
* if ( ! $this->config->get('purifier.finalize')) {
* $config->autoFinalize = false;
* }
* $config->load [$this->getConfig());
*
* You must NOT delete the default settings
* anything in settings should be compacted with params that needed to instance HTMLPurifier_Config.
*
* @link http://htmlpurifier.org/live/configdoc/plain.html
*/
// phpcs:disable Generic.Files.LineLength.TooLong
return [
'encoding' => 'UTF-8',
'finalize' => true,
'cachePath' => storage_path('app/purifier'),
'cacheFileMode' => 0755,
'settings' => [
'default' => [
'HTML.SafeIframe' => true,
// * For iframes
'URI.SafeIframeRegexp' => "%^(http://|https://|//)(www.youtube.com/embed/|player.vimeo.com/video/|vimeo.com/)%",
'HTML.Doctype' => 'HTML 4.01 Transitional',
// * Legacy filter en full avec safe legacy.
// 'HTML.Allowed' => 'h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],figure[class|style],oembed[url],div[style],b,strong,i,em,u,a[href|title|target|rel],ul[style],ol[style],dl[style],li,p[style],br,span[style|class],img[width|height|alt|src|style|class|title],iframe[src|width|height|class|frameborder|style],table[style|align|border|cellspacing|cellpadding|width],thead,tbody,th[colspan|align|valign|style],tr[align|valign|style],td[style|colspan|rowspan|scope|align|valign],hr,dl,dt',
// * Restricted basics.
'HTML.Allowed' => 'br,p[style],b,u,i,a[href|title|target|rel],strong,span[class],ul,ol,li,h2,h3,h4,hr',
// * Legacy filter en full avec safe legacy.
// 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align,list-style-type,float,width,height,vertical-align,border-color',
// * Restricted basics.
'CSS.AllowedProperties' => 'text-align',
'AutoFormat.AutoParagraph' => false,
'AutoFormat.RemoveEmpty' => false,
'AutoFormat.RemoveEmpty.RemoveNbsp' => false,
],
'custom_definition' => [
'id' => 'html5-definitions',
'rev' => 1,
'debug' => true,
'elements' => [
// * http://developers.whatwg.org/sections.html
['section', 'Block', 'Flow', 'Common'],
['nav', 'Block', 'Flow', 'Common'],
['article', 'Block', 'Flow', 'Common'],
['aside', 'Block', 'Flow', 'Common'],
['header', 'Block', 'Flow', 'Common'],
['footer', 'Block', 'Flow', 'Common'],
// * Content model actually excludes several tags, not modelled here
['address', 'Block', 'Flow', 'Common'],
['hgroup', 'Block', 'Required: h1 | h2 | h3 | h4 | h5 | h6', 'Common'],
// * http://developers.whatwg.org/grouping-content.html
['figure', 'Block', 'Optional: (figcaption, Flow) | (Flow, figcaption) | Flow', 'Common'],
['figcaption', 'Inline', 'Flow', 'Common'],
['oembed', 'Inline', 'Flow', 'Common'],
// * http://developers.whatwg.org/the-video-element.html#the-video-element
['video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [
'src' => 'URI',
'type' => 'Text',
'width' => 'Length',
'height' => 'Length',
'poster' => 'URI',
'preload' => 'Enum#auto,metadata,none',
'controls' => 'Bool',
]],
['source', 'Block', 'Flow', 'Common', [
'src' => 'URI',
'type' => 'Text',
]],
// * http://developers.whatwg.org/text-level-semantics.html
['s', 'Inline', 'Inline', 'Common'],
['var', 'Inline', 'Inline', 'Common'],
['sub', 'Inline', 'Inline', 'Common'],
['sup', 'Inline', 'Inline', 'Common'],
['mark', 'Inline', 'Inline', 'Common'],
['wbr', 'Inline', 'Empty', 'Core'],
// * http://developers.whatwg.org/edits.html
['ins', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
['del', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
],
'attributes' => [
['iframe', 'allowfullscreen', 'Bool'],
['table', 'height', 'Text'],
['td', 'border', 'Text'],
['th', 'border', 'Text'],
['tr', 'width', 'Text'],
['tr', 'height', 'Text'],
['tr', 'border', 'Text'],
['figure', 'class', 'Text'],
['figure', 'style', 'Text'],
['oembed', 'url', 'Text'],
['span', 'style', 'Text'],
['img', 'style', 'Text'],
],
],
'custom_attributes' => [
['a', 'target', 'Enum#_blank,_self,_target,_top'],
['ol', 'style', 'Text'],
['figure', 'style', 'Text'],
['img', 'style', 'Text'],
['span', 'style', 'Text'],
['oembed', 'url', 'Text'],
],
'custom_elements' => [
['u', 'Inline', 'Inline', 'Common'],
['oembed', 'Inline', 'Inline', 'Common'],
],
],
];

View file

@ -73,6 +73,22 @@ return [
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs

61
config/sentry.php Normal file
View file

@ -0,0 +1,61 @@
<?php
return [
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// The release version of your application.
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')).
'release' => env('SENTRY_RELEASE'),
// When left empty or `null` the Laravel environment will be used.
'environment' => env('SENTRY_ENVIRONMENT'),
'breadcrumbs' => [
// Capture Laravel logs in breadcrumbs.
'logs' => true,
// Capture SQL queries in breadcrumbs.
'sql_queries' => true,
// Capture bindings on SQL queries logged in breadcrumbs.
'sql_bindings' => false,
// Capture queue job information in breadcrumbs.
'queue_info' => true,
// Capture command information in breadcrumbs.
'command_info' => true,
],
'tracing' => [
// Trace queue jobs as their own transactions.
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false),
// Capture queue jobs as spans when executed on the sync driver.
'queue_jobs' => true,
// Capture SQL queries as spans.
'sql_queries' => true,
// Try to find out where the SQL query originated from and add it to the query spans.
'sql_origin' => true,
// Capture views as spans.
'views' => true,
// Indicates if the tracing integrations supplied by Sentry should be loaded.
'default_integrations' => true,
// Indicates that requests without a matching route should be traced.
'missing_routes' => false,
],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii.
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate.
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float)env('SENTRY_TRACES_SAMPLE_RATE'),
'before_send' => [\App\Lib\Helpers\ExceptionsHelper::class, 'sentryEventHandler'],
];

View file

@ -37,22 +37,9 @@ return [
'graph_access_token' => env('FACEBOOK_GRAPH_ACCESS_TOKEN'),
'enpoints' => [
'ig_top_media' => [
'fields' => env(
'FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_FIELDS',
implode(", ", [
"id",
"media_type",
"like_count",
"comments_count",
"permalink",
"timestamp",
"caption"
])
),
'ig_hashtag_id' => env('FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_IGHASHTAGID'),
'hashtag_id' => env('FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_HASHTAGID'),
'user_id' => env('FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_USERID'),
'limit' => env('FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_LIMIT', 25),
'nb_medias' => env('FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIAS', 30),
'media_count' => env('FACEBOOK_GRAPH_ENDPOINT_IG_TOPMEDIA_NBMEDIA')
]
]
]

View file

@ -15,6 +15,8 @@ return [
'paths' => [
resource_path('views'),
// ! for vendor publish (mail notifications par ex)
resource_path('views/vendor'),
],
/*

View file

@ -5,9 +5,6 @@ namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
@ -18,11 +15,12 @@ class UserFactory extends Factory
public function definition()
{
return [
'name' => fake()->name(),
'firstname' => fake()->firstname(),
'lastname' => fake()->lastname(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
// * password
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
// password
'remember_token' => Str::random(10),
];
}
@ -32,8 +30,9 @@ class UserFactory extends Factory
*
* @return static
*/
public function unverified()
public function unverified(): static
{
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);

View file

@ -15,7 +15,8 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('firstname');
$table->string('lastname');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');

View file

@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('password_resets', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('email')->index();
$table->string('token');
$table->timestamp('created_at')->nullable();
});

View file

@ -1,37 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('personal_access_tokens');
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('insta_media', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger("insta_id")->unique()
->comment('A unique instagram id that can be used for api calls');
$table->string("type")->comment('The instagram object type, commonly IMAGE or CAROUSEL_ALBUM');
$table->string('username')->comment('The instagram user name, scrapped using OG description');
$table->text('caption')->comment('The object text content');
$table->string("permalink")->comment('The uri where is stored the Instagram object');
$table->timestamp('timestamp')->comment('UTC timestamp from instagram api');
$table->unsignedInteger("comments_count");
$table->unsignedInteger("like_count")->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('insta_medias');
}
};

View file

@ -1,39 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('insta_medias', function (Blueprint $table) {
$table->id();
$table->string("insta_id")->unique();
$table->string("type");
$table->string("permalink");
$table->string("timestamp");
$table->bigInteger("comments_count");
$table->bigInteger("like_count")->nullable();
$table->string('username');
$table->text('caption');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('insta_medias');
}
};

View file

@ -13,10 +13,10 @@ return new class extends Migration
*/
public function up()
{
Schema::create('insta_media_excludes', function (Blueprint $table) {
Schema::create('insta_media_exclusions', function (Blueprint $table) {
$table->id();
$table->unsignedTinyInteger('type')->comment("Enum 1 = post, 2 = user");
$table->string('value');
$table->unsignedTinyInteger('type')->comment("ExcludeTypeEnum 1 = post, 2 = user");
$table->string('value')->comment('Post: ig id, User : ig username');
$table->timestamps();
});
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};

View file

@ -2,7 +2,6 @@
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
@ -14,11 +13,5 @@ class DatabaseSeeder extends Seeder
*/
public function run()
{
// \App\Models\User::factory(10)->create();
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
}
}

3
jsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"include": ["./resources/js/**/*"]
}

View file

@ -1,20 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'These credentials do not match our records.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
];

View file

@ -1,19 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Previous',
'next' => 'Next &raquo;',
];

View file

@ -1,22 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'reset' => 'Your password has been reset!',
'sent' => 'We have emailed your password reset link!',
'throttled' => 'Please wait before retrying.',
'token' => 'This password reset token is invalid.',
'user' => "We can't find a user with that email address.",
];

View file

@ -1,184 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute must be accepted.',
'accepted_if' => 'The :attribute must be accepted when :other is :value.',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'alpha' => 'The :attribute must only contain letters.',
'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.',
'alpha_num' => 'The :attribute must only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'ascii' => 'The :attribute must only contain single-byte alphanumeric characters and symbols.',
'before' => 'The :attribute must be a date before :date.',
'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
'between' => [
'array' => 'The :attribute must have between :min and :max items.',
'file' => 'The :attribute must be between :min and :max kilobytes.',
'numeric' => 'The :attribute must be between :min and :max.',
'string' => 'The :attribute must be between :min and :max characters.',
],
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'current_password' => 'The password is incorrect.',
'date' => 'The :attribute is not a valid date.',
'date_equals' => 'The :attribute must be a date equal to :date.',
'date_format' => 'The :attribute does not match the format :format.',
'decimal' => 'The :attribute must have :decimal decimal places.',
'declined' => 'The :attribute must be declined.',
'declined_if' => 'The :attribute must be declined when :other is :value.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'doesnt_end_with' => 'The :attribute may not end with one of the following: :values.',
'doesnt_start_with' => 'The :attribute may not start with one of the following: :values.',
'email' => 'The :attribute must be a valid email address.',
'ends_with' => 'The :attribute must end with one of the following: :values.',
'enum' => 'The selected :attribute is invalid.',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'array' => 'The :attribute must have more than :value items.',
'file' => 'The :attribute must be greater than :value kilobytes.',
'numeric' => 'The :attribute must be greater than :value.',
'string' => 'The :attribute must be greater than :value characters.',
],
'gte' => [
'array' => 'The :attribute must have :value items or more.',
'file' => 'The :attribute must be greater than or equal to :value kilobytes.',
'numeric' => 'The :attribute must be greater than or equal to :value.',
'string' => 'The :attribute must be greater than or equal to :value characters.',
],
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'ipv4' => 'The :attribute must be a valid IPv4 address.',
'ipv6' => 'The :attribute must be a valid IPv6 address.',
'json' => 'The :attribute must be a valid JSON string.',
'lowercase' => 'The :attribute must be lowercase.',
'lt' => [
'array' => 'The :attribute must have less than :value items.',
'file' => 'The :attribute must be less than :value kilobytes.',
'numeric' => 'The :attribute must be less than :value.',
'string' => 'The :attribute must be less than :value characters.',
],
'lte' => [
'array' => 'The :attribute must not have more than :value items.',
'file' => 'The :attribute must be less than or equal to :value kilobytes.',
'numeric' => 'The :attribute must be less than or equal to :value.',
'string' => 'The :attribute must be less than or equal to :value characters.',
],
'mac_address' => 'The :attribute must be a valid MAC address.',
'max' => [
'array' => 'The :attribute must not have more than :max items.',
'file' => 'The :attribute must not be greater than :max kilobytes.',
'numeric' => 'The :attribute must not be greater than :max.',
'string' => 'The :attribute must not be greater than :max characters.',
],
'max_digits' => 'The :attribute must not have more than :max digits.',
'mimes' => 'The :attribute must be a file of type: :values.',
'mimetypes' => 'The :attribute must be a file of type: :values.',
'min' => [
'array' => 'The :attribute must have at least :min items.',
'file' => 'The :attribute must be at least :min kilobytes.',
'numeric' => 'The :attribute must be at least :min.',
'string' => 'The :attribute must be at least :min characters.',
],
'min_digits' => 'The :attribute must have at least :min digits.',
'missing' => 'The :attribute field must be missing.',
'missing_if' => 'The :attribute field must be missing when :other is :value.',
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
'missing_with' => 'The :attribute field must be missing when :values is present.',
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
'multiple_of' => 'The :attribute must be a multiple of :value.',
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'password' => [
'letters' => 'The :attribute must contain at least one letter.',
'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.',
'numbers' => 'The :attribute must contain at least one number.',
'symbols' => 'The :attribute must contain at least one symbol.',
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
],
'present' => 'The :attribute field must be present.',
'prohibited' => 'The :attribute field is prohibited.',
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
'prohibits' => 'The :attribute field prohibits :other from being present.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size' => [
'array' => 'The :attribute must contain :size items.',
'file' => 'The :attribute must be :size kilobytes.',
'numeric' => 'The :attribute must be :size.',
'string' => 'The :attribute must be :size characters.',
],
'starts_with' => 'The :attribute must start with one of the following: :values.',
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid timezone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'uppercase' => 'The :attribute must be uppercase.',
'url' => 'The :attribute must be a valid URL.',
'ulid' => 'The :attribute must be a valid ULID.',
'uuid' => 'The :attribute must be a valid UUID.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];

9109
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,69 @@
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
"prod": "vite build --mode production",
"dev": "NODE_ENV=development vite build -d --mode development",
"watch": "NODE_ENV=development vite build --mode development --watch",
"eslint": "node ./node_modules/.bin/eslint --max-warnings 0 -c .eslintrc.json ./resources",
"eslint-fix": "node ./node_modules/.bin/eslint --fix --max-warnings 0 -c .eslintrc.json ./resources",
"eslint-ci": "node ./node_modules/.bin/eslint --max-warnings 0 -c .eslintrc.json ./resources -f junit",
"stylelint": "node ./node_modules/.bin/stylelint --config './.stylelintrc.json' --max-warnings 0 '**/resources/**/*.scss'",
"stylelint-fix": "node ./node_modules/.bin/stylelint --config './.stylelintrc.json' --fix --max-warnings 0 '**/resources/**/*.scss'",
"stylelint-ci": "node ./node_modules/.bin/stylelint --config './.stylelintrc.json' --max-warnings 0 '**/resources/**/*.scss' -f json"
},
"devDependencies": {
"@babel/types": "^7.21.2",
"@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-brands-svg-icons": "^6.1.2",
"@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/vue-fontawesome": "^3.0.1",
"@rushstack/eslint-patch": "^1.1.4",
"@sipec/vue3-tags-input": "^3.0.4",
"@types/bootstrap": "^5.2.6",
"@types/cropperjs": "^1.3.0",
"@types/toastify-js": "^1.11.1",
"@types/webpack-env": "^1.18.0",
"@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2",
"@vitejs/plugin-legacy": "^4.0.1",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"axios": "^1.1.2",
"bootstrap": "^5.2.3",
"cropperjs": "^1.5.12",
"eslint": "^8.35.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-vue": "^9.4.0",
"laravel-vite-plugin": "^0.7.2",
"lodash": "^4.17.19",
"postcss": "^8.1.14",
"vite": "^4.0.0"
"postcss": "^8.4.21",
"postcss-discard-comments": "^5.1.2",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.7.1",
"resolve-url-loader": "^5.0.0",
"sass": "^1.58.3",
"slugify": "^1.6.5",
"stylelint": "^14.16.1",
"stylelint-config-idiomatic-order": "^8.1.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-sass-guidelines": "^9.0.1",
"stylelint-config-standard-scss": "^6.1.0",
"stylelint-scss": "^4.4.0",
"stylelint-webpack-plugin": "^3.3.0",
"sweetalert2": "^11.4.8",
"toastify-js": "^1.12.0",
"ts-loader": "^9.3.1",
"typescript": "^4.9.5",
"typescript-eslint": "^0.0.1-alpha.0",
"vite": "^4.0.0",
"vite-plugin-checker": "^0.5.6",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-stylelint": "^4.2.0",
"vue": "^3.2.38",
"vue-eslint-parser": "^9.1.0",
"vue-loader": "^16.8.3",
"vue-tsc": "^1.2.0",
"vue3-nestable": "^3.0.3"
}
}

View file

@ -20,6 +20,7 @@
<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
<rule ref="Generic.CodeAnalysis.UnusedFunctionParameter"/>
<rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
<rule ref="Generic.Commenting.DocComment.SpacingBeforeTags"/>
<rule ref="Generic.Commenting.Todo"/>
<rule ref="Generic.Commenting.Fixme"/>
<rule ref="Generic.ControlStructures.InlineControlStructure"/>

View file

@ -12,7 +12,7 @@
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>

View file

@ -1,2 +1,2 @@
User-agent: *
Disallow:
Disallow: /

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1 +0,0 @@
import './bootstrap';

View file

@ -1,35 +0,0 @@
import _ from 'lodash';
window._ = _;
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo';
// import Pusher from 'pusher-js';
// window.Pusher = Pusher;
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: import.meta.env.VITE_PUSHER_APP_KEY,
// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
// enabledTransports: ['ws', 'wss'],
// });

View file

@ -0,0 +1 @@
node_modules

49
resources/js/vendor/ckeditor/LICENSE.md vendored Normal file
View file

@ -0,0 +1,49 @@
Software License Agreement
==========================
Copyright (c) 2014-2021, CKSource - Frederico Knabben. All rights reserved.
Online builder code samples are licensed under the terms of the MIT License (see Appendix A):
http://en.wikipedia.org/wiki/MIT_License
CKEditor 5 collaboration features are only available under a commercial license. [Contact us](https://ckeditor.com/contact/) for more details.
Free 30-days trials of CKEditor 5 collaboration features are available:
* https://ckeditor.com/collaboration/ - Real-time collaboration (with all features).
* https://ckeditor.com/collaboration/comments/ - Inline comments feature (without real-time collaborative editing).
* https://ckeditor.com/collaboration/track-changes/ - Track changes feature (without real-time collaborative editing).
Trademarks
----------
CKEditor is a trademark of CKSource - Frederico Knabben. All other brand
and product names are trademarks, registered trademarks or service
marks of their respective holders.
---
Appendix A: The MIT License
---------------------------
The MIT License (MIT)
Copyright (c) 2014-2021, CKSource - Frederico Knabben
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

68
resources/js/vendor/ckeditor/README.md vendored Normal file
View file

@ -0,0 +1,68 @@
# CKEditor 5 editor generated with the online builder
This repository presents a CKEditor 5 editor build generated by the [Online builder tool](https://ckeditor.com/ckeditor-5/online-builder)
## Quick start
1. Open the `sample/index.html` page in the browser.
If you picked the real-time collaboration plugins:
2. Fill the dialog with correct token, websocket and upload URL endpoints. If you do not have these yet or do not know their meaning, [contact us](https://ckeditor.com/contact/).
3. Copy the URL and share it or paste in another tab to enjoy real-time collaborative editing.
If you picked the non-real-time collaboration plugins:
2. Fill the prompt with the license key. If you do not have the license key yet [contact us](https://ckeditor.com/contact/).
## Configuring build
Changes like changing toolbar items, changing order of icons or customizing plugin configurations should be relatively easy to make. Open the `sample/index.html` file and edit the script that initialized the CKEditor 5. Save the file and refresh the browser. That's all.
*Note:* If you have any problems with browser caching use the `Ctrl + R` or `Cmd + R` shortcut depending on your system.
However if you want to remove or add a plugin to the build you need to follow the next step of this guide.
Note that it is also possible to go back to the [Online builder tool](https://ckeditor.com/ckeditor-5/online-builder) and pick other set of plugins. But we encourage you to try the harder way and to learn the principles of Node.js and CKEditor 5 ecosystems that will allow you to do more cool things in the future!
### Installation
In order to rebuild the application you need to install all dependencies first. To do it, open the terminal in the project directory and type:
```
npm install
```
Make sure that you have the `node` and `npm` installed first. If not, then follow the instructions on the [Node.js documentation page](https://nodejs.org/en/).
### Adding or removing plugins
Now you can install additional plugin in the build. Just follow the [Adding a plugin to an editor tutorial](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/installing-plugins.html#adding-a-plugin-to-an-editor)
### Rebuilding editor
If you have already done the [Installation](#installation) and [Adding or removing plugins](#adding-or-removing-plugins) steps, you're ready to rebuild the editor by running the following command:
```
npm run build
```
This will build the CKEditor 5 to the `build` directory. You can open your browser and you should be able to see the changes you've made in the code. If not, then try to refresh also the browser cache by typing `Ctrl + R` or `Cmd + R` depending on your system.
## What's next?
Follow the guides available on https://ckeditor.com/docs/ckeditor5/latest/framework/index.html and enjoy the document editing.
## FAQ
| Where is the place to report bugs and feature requests?
You can create an issue on https://github.com/ckeditor/ckeditor5/issues including the build id - `unyb7c61pdjq-xt0bz4ltrghq`. Make sure that the question / problem is unique, please look for a possibly asked questions in the search box. Duplicates will be closed.
| Where can I learn more about the CKEditor 5 framework?
Here: https://ckeditor.com/docs/ckeditor5/latest/framework/
| Is it possible to use online builder with common frameworks like React, Vue or Angular?
Not yet, but it these integrations will be available at some point in the future.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
!function(e){const n=e.af=e.af||{};n.dictionary=Object.assign(n.dictionary||{},{"%0 of %1":"%0 van %1","Align center":"Belyn in die middel","Align left":"Belyn links","Align right":"Belyn regs","Block quote":"Verwysingsaanhaling",Bold:"Vet",Cancel:"Kanselleer",Italic:"Kursief",Justify:"Belyn beide kante","Remove color":"Verwyder kleur","Remove Format":"Verwyder formatering",Save:"Stoor","Show more items":"Wys meer items","Text alignment":"Teksbelyning","Text alignment toolbar":"Teksbelyning nutsbank",Underline:"Onderstreep"}),n.getPluralForm=function(e){return 1!=e}}(window.CKEDITOR_TRANSLATIONS||(window.CKEDITOR_TRANSLATIONS={}));

View file

@ -0,0 +1 @@
!function(e){const t=e.ar=e.ar||{};t.dictionary=Object.assign(t.dictionary||{},{"%0 of %1":"%0 من %1","Align center":"محاذاة في المنتصف","Align left":"محاذاة لليسار","Align right":"محاذاة لليمين",Aquamarine:"أخضر زبرجد",Big:"كبير",Black:"أسود","Block quote":"اقتباس",Blue:"أزرق",Bold:"عريض","Bulleted List":"قائمة نقطية","Bulleted list styles toolbar":"شريط أدوات أنماط قوائم التعداد النقطي",Cancel:"إلغاء","Characters: %0":"الأحرف: %0","Choose heading":"اختر عنوان",Circle:"دائرة",Decimal:"رقم عشري","Decimal with leading zero":"رقم عشري مع البدء بصفر ","Decrease indent":"تقليص المسافة البادئة",Default:"افتراضي","Dim grey":"رمادي خافت",Disc:"قرص","Document colors":"ألوان المستند",Downloadable:"يمكن تنزيله","Dropdown toolbar":"شريط أدوات القائمة المنسدلة","Edit block":"كتلة التحرير","Edit link":"تحرير الرابط","Editor block content toolbar":"شريط المحرر لأدوات كتلة المحتوى","Editor contextual toolbar":"شريط المحرر للأدوات السياقية","Editor editing area: %0":"منطقة تحرير المحرر: %0","Editor toolbar":"شريط أدوات المحرر","Font Color":"لون الخط","Font Family":"نوع الخط","Font Size":"حجم الخط",Green:"أخضر",Grey:"رمادي",Heading:"عنوان","Heading 1":"عنوان 1","Heading 2":"عنوان 2","Heading 3":"عنوان 3","Heading 4":"عنوان 4","Heading 5":"عنوان 5","Heading 6":"عنوان 6","Horizontal line":"خط أفقي",Huge:"ضخم","Increase indent":"زيادة المسافة البادئة","Insert paragraph after block":"إدراج فقرة بعد الكتلة","Insert paragraph before block":"إدراج فقرة قبل الكتلة",Italic:"مائل",Justify:"ضبط","Light blue":"أزرق فاتح","Light green":"أخضر فاتح","Light grey":"رمادي فاتح",Link:"رابط","Link URL":"رابط عنوان","List properties":"خصائص القائمة","Lower-latin":"حرف لاتيني صغير","Lower–roman":"رقم روماني صغير",Next:"التالي","Numbered List":"قائمة رقمية","Numbered list styles toolbar":"شريط أدوات أنماط القوائم المرقمة","Open in a new tab":"فتح في تبويب جديد","Open link in new tab":"فتح الرابط في تبويب جديد",Orange:"برتقالي",Paragraph:"فقرة","Press Enter to type after or press Shift + Enter to type before the widget":"اضغط على Enter للكتابة بعد ذلك أو اضغط على Shift + Enter للكتابة قبل الأداة",Previous:"السابق",Purple:"أرجواني",Red:"أحمر",Redo:"إعادة","Remove color":"إزالة اللون","Remove Format":"إزالة التنسيق","Reversed order":"عكس اتجاه الترتيب","Rich Text Editor":"معالج نصوص",Save:"حفظ","Select all":"تحديد الكل","Show more items":"عرض المزيد من العناصر",Small:"صغير",Source:"المصدر",Square:"مربع","Start at":"البدء من","Start index must be greater than 0.":"يجب أن تكون قيمة البداية في الفهرس أكبر من 0.","Text alignment":"محاذاة النص","Text alignment toolbar":"شريط أدوات محاذاة النص","This link has no URL":"لا يحتوي هذا الرابط على عنوان",Tiny:"ضئيل","Toggle the circle list style":"تبديل نمط قائمة الدوائر","Toggle the decimal list style":"تبديل نمط قائمة الأرقام العشرية","Toggle the decimal with leading zero list style":"تبديل الأرقام العشرية بنمط قائمة الأصفار البادئة","Toggle the disc list style":"تبديل نمط قائمة الأقراص","Toggle the lower–latin list style":"تبديل نمط قائمة الأحرف اللاتينية الصغيرة","Toggle the lower–roman list style":"تبديل نمط قائمة الأرقام الرومانية الصغيرة","Toggle the square list style":"تبديل نمط قائمة المربعات","Toggle the upper–latin list style":"تبديل نمط قائمة الأحرف اللاتينية الكبيرة","Toggle the upper–roman list style":"تبديل نمط قائمة الأرقام الرومانية الكبيرة",Turquoise:"فيروزي",Underline:"تحته خط",Undo:"تراجع",Unlink:"إلغاء الرابط","Upper-latin":"حرف لاتيني كبير","Upper-roman":"رقم روماني كبير",White:"أبيض","Widget toolbar":"شريط أدوات الواجهة","Words: %0":"الكلمات: %0",Yellow:"أصفر"}),t.getPluralForm=function(e){return 0==e?0:1==e?1:2==e?2:e%100>=3&&e%100<=10?3:e%100>=11&&e%100<=99?4:5}}(window.CKEDITOR_TRANSLATIONS||(window.CKEDITOR_TRANSLATIONS={}));

View file

@ -0,0 +1 @@
!function(e){const t=e.ast=e.ast||{};t.dictionary=Object.assign(t.dictionary||{},{"%0 of %1":"",Aquamarine:"",Black:"",Blue:"",Bold:"Negrina","Bulleted List":"Llista con viñetes","Bulleted list styles toolbar":"",Cancel:"Encaboxar",Circle:"",Decimal:"","Decimal with leading zero":"","Dim grey":"",Disc:"",Downloadable:"","Dropdown toolbar":"","Edit block":"","Edit link":"","Editor block content toolbar":"","Editor contextual toolbar":"","Editor editing area: %0":"","Editor toolbar":"",Green:"",Grey:"",Italic:"Cursiva","Light blue":"","Light green":"","Light grey":"",Link:"Enllazar","Link URL":"URL del enllaz","List properties":"","Lower-latin":"","Lower–roman":"",Next:"","Numbered List":"Llista numberada","Numbered list styles toolbar":"","Open in a new tab":"","Open link in new tab":"",Orange:"",Previous:"",Purple:"",Red:"",Redo:"Refacer","Remove color":"","Reversed order":"","Rich Text Editor":"Editor de testu arriquecíu",Save:"Guardar","Show more items":"",Square:"","Start at":"","Start index must be greater than 0.":"","This link has no URL":"","Toggle the circle list style":"","Toggle the decimal list style":"","Toggle the decimal with leading zero list style":"","Toggle the disc list style":"","Toggle the lower–latin list style":"","Toggle the lower–roman list style":"","Toggle the square list style":"","Toggle the upper–latin list style":"","Toggle the upper–roman list style":"",Turquoise:"",Underline:"",Undo:"Desfacer",Unlink:"Desenllazar","Upper-latin":"","Upper-roman":"",White:"",Yellow:""}),t.getPluralForm=function(e){return 1!=e}}(window.CKEDITOR_TRANSLATIONS||(window.CKEDITOR_TRANSLATIONS={}));

View file

@ -0,0 +1 @@
!function(e){const i=e.az=e.az||{};i.dictionary=Object.assign(i.dictionary||{},{"%0 of %1":"%1-dən %0","Align center":"Mərkəzə düzləndir","Align left":"Soldan düzləndir","Align right":"Sağdan düzləndir",Aquamarine:"Akvamarin",Big:"Böyük",Black:"Qara","Block quote":"Sitat bloku",Blue:"Mavi",Bold:"Yarıqalın","Bulleted List":"Markerlənmiş siyahı","Bulleted list styles toolbar":"",Cancel:"İmtina et","Characters: %0":"Simvolar: %0","Choose heading":"Başlıqı seç",Circle:"",Decimal:"","Decimal with leading zero":"","Decrease indent":"Boş yeri kiçilt",Default:"Default","Dim grey":"Tünd boz",Disc:"","Document colors":"Rənglər",Downloadable:"Yüklənə bilər","Dropdown toolbar":"Açılan paneli","Edit block":"Redaktə etmək bloku","Edit link":"Linki redaktə et","Editor block content toolbar":"","Editor contextual toolbar":"","Editor editing area: %0":"","Editor toolbar":"Redaktorun paneli","Font Color":"Şrift Rəngi","Font Family":"Şrift ailəsi","Font Size":"Şrift ölçüsü",Green:"Yaşıl",Grey:"Boz",Heading:"Başlıq","Heading 1":"Başlıq 1","Heading 2":"Başlıq 2","Heading 3":"Başlıq 3","Heading 4":"Başlıq 4","Heading 5":"Başlıq 5","Heading 6":"Başlıq 6","Horizontal line":"Üfüqi xətt",Huge:"Nəhəng","Increase indent":"Boş yeri böyüt","Insert paragraph after block":"","Insert paragraph before block":"",Italic:"Maili",Justify:"Eninə görə","Light blue":"Açıq mavi","Light green":"Açıq yaşıl","Light grey":"Açıq boz",Link:"Əlaqələndir","Link URL":"Linkin URL","List properties":"","Lower-latin":"","Lower–roman":"",Next:"Növbəti","Numbered List":"Nömrələnmiş siyahı","Numbered list styles toolbar":"","Open in a new tab":"Yeni pəncərədə aç","Open link in new tab":"Linki yeni pəncərədə aç",Orange:"Narıncı",Paragraph:"Abzas","Press Enter to type after or press Shift + Enter to type before the widget":"",Previous:"Əvvəlki",Purple:"Bənövşəyi",Red:"Qırmızı",Redo:"Təkrar et","Remove color":"Rəngi ləğv et","Remove Format":"Formatı Ləğv Et","Reversed order":"","Rich Text Editor":"Rich Text Redaktoru",Save:"Yadda saxla","Show more items":"Daha çox əşyanı göstərin",Small:"Kiçik",Square:"","Start at":"","Start index must be greater than 0.":"","Text alignment":"Mətn düzləndirməsi","Text alignment toolbar":"Mətnin düzləndirmə paneli","This link has no URL":"Bu linkdə URL yoxdur",Tiny:"Miniatür","Toggle the circle list style":"","Toggle the decimal list style":"","Toggle the decimal with leading zero list style":"","Toggle the disc list style":"","Toggle the lower–latin list style":"","Toggle the lower–roman list style":"","Toggle the square list style":"","Toggle the upper–latin list style":"","Toggle the upper–roman list style":"",Turquoise:"Firuzəyi",Underline:"Altdan xətt",Undo:"İmtina et",Unlink:"Linki sil","Upper-latin":"","Upper-roman":"",White:"Ağ","Widget toolbar":"Vidgetin paneli","Words: %0":"Sözlər: %0",Yellow:"Sarı"}),i.getPluralForm=function(e){return 1!=e}}(window.CKEDITOR_TRANSLATIONS||(window.CKEDITOR_TRANSLATIONS={}));

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more