mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 12:51:00 +00:00
feat(app): fresh module-platform, provider, middleware, rule and support implementation
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Adapters\Contacts;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Contracts\CustomerStatsProvider;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* One customer's fiscal year in numbers: twelve monthly buckets of invoiced,
|
||||
* spent and received money, plus the totals for the window as a whole.
|
||||
*
|
||||
* The window opens on the month named by the first dash-separated part of the
|
||||
* company's `fiscal_year` preference. Anything that part cannot be read as a
|
||||
* number, and the shipped default, the word "calendar_year", is exactly that,
|
||||
* intval()s to zero; month zero rolls Carbon back into December of the year
|
||||
* before, so those companies get a window opening the previous December. It is
|
||||
* a real, user-visible defect and it is reproduced here on purpose: the
|
||||
* dashboard walks the same window the same way, and the two have to keep
|
||||
* agreeing until they are fixed together.
|
||||
*
|
||||
* KNOWN QUIRK: only the fiscal-year lookup uses the $companyId that is passed
|
||||
* in. Every figure below is scoped by whereCompany(), which reads the company
|
||||
* header off the current request instead.
|
||||
*/
|
||||
class EloquentCustomerStatsProvider implements CustomerStatsProvider
|
||||
{
|
||||
public function get(Customer $customer, int $companyId, bool $previousYear = false): array
|
||||
{
|
||||
$openingMonth = intval(explode('-', CompanySetting::getSetting('fiscal_year', $companyId))[0]);
|
||||
|
||||
// Three cursors set to the same instant: the fixed left edge of the
|
||||
// whole window, and the pair that walks it one month at a time.
|
||||
$windowStart = Carbon::now();
|
||||
$monthStart = Carbon::now();
|
||||
$monthEnd = Carbon::now();
|
||||
|
||||
// An opening month still ahead of us in the calendar year belongs to
|
||||
// the fiscal year that opened twelve months ago.
|
||||
$openedLastYear = $openingMonth > $monthStart->month;
|
||||
|
||||
foreach ([$windowStart, $monthStart, $monthEnd] as $cursor) {
|
||||
if ($openedLastYear) {
|
||||
$cursor->subYear();
|
||||
}
|
||||
|
||||
$cursor->month($openingMonth);
|
||||
}
|
||||
|
||||
$windowStart->startOfMonth();
|
||||
$monthStart->startOfMonth();
|
||||
$monthEnd->endOfMonth();
|
||||
|
||||
if ($previousYear) {
|
||||
$windowStart->subYear()->startOfMonth();
|
||||
$monthStart->subYear()->startOfMonth();
|
||||
$monthEnd->subYear()->endOfMonth();
|
||||
}
|
||||
|
||||
$months = [];
|
||||
$invoiceTotals = [];
|
||||
$expenseTotals = [];
|
||||
$receiptTotals = [];
|
||||
$netProfits = [];
|
||||
|
||||
for ($bucket = 0; $bucket < 12; $bucket++) {
|
||||
$bucketSpan = [$monthStart->format('Y-m-d'), $monthEnd->format('Y-m-d')];
|
||||
|
||||
$invoiceTotals[] = Invoice::query()
|
||||
->whereBetween('invoice_date', $bucketSpan)
|
||||
->whereCompany()
|
||||
->whereCustomer($customer->id)
|
||||
->sum('base_total');
|
||||
|
||||
$expenseTotals[] = Expense::query()
|
||||
->whereBetween('expense_date', $bucketSpan)
|
||||
->whereCompany()
|
||||
->whereUser($customer->id)
|
||||
->sum('base_amount');
|
||||
|
||||
$receiptTotals[] = Payment::query()
|
||||
->whereBetween('payment_date', $bucketSpan)
|
||||
->whereCompany()
|
||||
->whereCustomer($customer->id)
|
||||
->sum('base_amount');
|
||||
|
||||
// What was received less what was spent. Invoiced money is not in
|
||||
// it: a bill that has not been paid is not profit.
|
||||
$netProfits[] = $receiptTotals[$bucket] - $expenseTotals[$bucket];
|
||||
|
||||
$months[] = $monthStart->translatedFormat('M');
|
||||
|
||||
// Both cursors step off the first of their month, so a short month
|
||||
// can never drag the walk backwards.
|
||||
$monthEnd->startOfMonth()->addMonth()->endOfMonth();
|
||||
$monthStart->addMonth()->startOfMonth();
|
||||
}
|
||||
|
||||
// Twelve steps left the walking cursor on the month after the window.
|
||||
// Back it on to the last month of the window and take that month's
|
||||
// final day as the right edge of the whole-window figures.
|
||||
$monthStart->subMonth()->endOfMonth();
|
||||
|
||||
$windowSpan = [$windowStart->format('Y-m-d'), $monthStart->format('Y-m-d')];
|
||||
|
||||
$salesTotal = Invoice::query()
|
||||
->whereBetween('invoice_date', $windowSpan)
|
||||
->whereCompany()
|
||||
->whereCustomer($customer->id)
|
||||
->sum('base_total');
|
||||
|
||||
$totalReceipts = Payment::query()
|
||||
->whereBetween('payment_date', $windowSpan)
|
||||
->whereCompany()
|
||||
->whereCustomer($customer->id)
|
||||
->sum('base_amount');
|
||||
|
||||
$totalExpenses = Expense::query()
|
||||
->whereBetween('expense_date', $windowSpan)
|
||||
->whereCompany()
|
||||
->whereUser($customer->id)
|
||||
->sum('base_amount');
|
||||
|
||||
return [
|
||||
'months' => $months,
|
||||
'invoiceTotals' => $invoiceTotals,
|
||||
'expenseTotals' => $expenseTotals,
|
||||
'receiptTotals' => $receiptTotals,
|
||||
// KNOWN QUIRK: both sides are cut to whole units before the
|
||||
// subtraction, so the headline figure loses the cents that the
|
||||
// three totals beside it keep.
|
||||
'netProfit' => (int) $totalReceipts - (int) $totalExpenses,
|
||||
'netProfits' => $netProfits,
|
||||
'salesTotal' => $salesTotal,
|
||||
'totalReceipts' => $totalReceipts,
|
||||
'totalExpenses' => $totalExpenses,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies as FrameworkEncryptCookies;
|
||||
|
||||
/**
|
||||
* Application hook into the framework's cookie encryption pass.
|
||||
*
|
||||
* It exists so the app owns the opt-out list; nothing else is customised.
|
||||
*/
|
||||
class EncryptCookies extends FrameworkEncryptCookies
|
||||
{
|
||||
/**
|
||||
* Whether cookie payloads are run through PHP's serializer before being
|
||||
* encrypted. Left off, matching the framework default.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected static $serialize = false;
|
||||
|
||||
/**
|
||||
* Cookie names that travel in clear text. Nothing is exempt right now.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as FrameworkPreventRequestForgery;
|
||||
|
||||
/**
|
||||
* Application hook into the framework's CSRF token check.
|
||||
*
|
||||
* Only two endpoints opt out, both of them credential posts that are reached
|
||||
* before a session token can reasonably be in hand.
|
||||
*/
|
||||
class PreventRequestForgery extends FrameworkPreventRequestForgery
|
||||
{
|
||||
/**
|
||||
* Whether responses carry the readable XSRF-TOKEN cookie the SPA reads
|
||||
* back when signing its own requests.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $addHttpCookie = true;
|
||||
|
||||
/**
|
||||
* Request paths the token check skips.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
'login',
|
||||
'installation/session-login',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\TrimStrings as FrameworkTrimStrings;
|
||||
|
||||
/**
|
||||
* Application hook into the framework's whitespace-trimming pass over the
|
||||
* request payload.
|
||||
*/
|
||||
class TrimStrings extends FrameworkTrimStrings
|
||||
{
|
||||
/**
|
||||
* Input keys handed through untouched, since a leading or trailing space
|
||||
* is a legitimate part of the value.
|
||||
*
|
||||
* Note this replaces the framework list rather than extending it, so
|
||||
* `current_password` is trimmed here even though the framework spares it.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustProxies as FrameworkTrustProxies;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Teaches the request object which upstream proxies may rewrite the client's
|
||||
* address, host, port and scheme.
|
||||
*/
|
||||
class TrustProxies extends FrameworkTrustProxies
|
||||
{
|
||||
/**
|
||||
* Proxies the application accepts forwarded headers from, resolved lazily
|
||||
* by {@see self::proxies()}.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $proxies;
|
||||
|
||||
/**
|
||||
* Bitmask of the forwarding headers that are honoured.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $headers = Request::HEADER_X_FORWARDED_FOR
|
||||
| Request::HEADER_X_FORWARDED_HOST
|
||||
| Request::HEADER_X_FORWARDED_PORT
|
||||
| Request::HEADER_X_FORWARDED_PROTO
|
||||
| Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||
|
||||
/**
|
||||
* Resolve the trusted proxy list.
|
||||
*
|
||||
* Defaults to trusting every hop, which suits the containerised installs
|
||||
* that sit behind an operator-controlled reverse proxy.
|
||||
*
|
||||
* @return string|array|null
|
||||
*/
|
||||
protected function proxies()
|
||||
{
|
||||
$this->proxies = env('TRUSTED_PROXIES', '*');
|
||||
|
||||
return $this->proxies;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Http;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
/**
|
||||
* The class every controller in the application is built on.
|
||||
*
|
||||
* It adds no behaviour of its own. What it does is settle, in one place, the
|
||||
* three helper sets a controller may assume are there: authorisation
|
||||
* ($this->authorize(), authorizeForUser(), authorizeResource()), job dispatch
|
||||
* ($this->dispatch(), dispatchSync()) and inline validation ($this->validate()).
|
||||
* Controllers across the whole codebase call into all three, so the list below
|
||||
* is a contract with them: dropping a trait breaks call sites far from here,
|
||||
* and the framework's own base class deliberately ships without them.
|
||||
*/
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use DispatchesJobs;
|
||||
use ValidatesRequests;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Console;
|
||||
|
||||
use App\Platform\Modules\Runtime\ModuleInstaller;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Finishes an installation whose module files are already sitting on disk.
|
||||
*/
|
||||
class InstallModuleCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'install:module {module} {version}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Install cloned module.';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the module name and version to the runtime installer.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$name = $this->argument('module');
|
||||
$version = $this->argument('version');
|
||||
|
||||
ModuleInstaller::complete($name, $version);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Announces that a module has been switched off for this installation.
|
||||
*/
|
||||
class ModuleDisabledEvent
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithSockets;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Subject of the announcement. Deliberately untyped: the property and the
|
||||
* constructor parameter are a published contract for module listeners.
|
||||
*/
|
||||
public $module;
|
||||
|
||||
public function __construct($module)
|
||||
{
|
||||
$this->module = $module;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Announces that a module has been switched on for this installation.
|
||||
*/
|
||||
class ModuleEnabledEvent
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithSockets;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Subject of the announcement. Deliberately untyped: the property and the
|
||||
* constructor parameter are a published contract for module listeners.
|
||||
*/
|
||||
public $module;
|
||||
|
||||
public function __construct($module)
|
||||
{
|
||||
$this->module = $module;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Announces that a module's files, migrations and registry row are in place.
|
||||
*/
|
||||
class ModuleInstalledEvent
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithSockets;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Subject of the announcement. Deliberately untyped: the property and the
|
||||
* constructor parameter are a published contract for module listeners.
|
||||
*/
|
||||
public $module;
|
||||
|
||||
public function __construct($module)
|
||||
{
|
||||
$this->module = $module;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Http\Controllers\Assets;
|
||||
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Modules\Runtime\ModuleAssetVersion;
|
||||
use DateTime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ScriptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Stream one script that a module published from its ServiceProvider::boot()
|
||||
* through \InvoiceShelf\Modules\Registry::registerScript($name, $path).
|
||||
*
|
||||
* A "v" query value equal to the hash of the bytes being served earns a
|
||||
* year-long immutable cache; every other request is answered no-store so a
|
||||
* rebuilt asset is never hidden behind a stale copy.
|
||||
*
|
||||
* @throws NotFoundHttpException
|
||||
*/
|
||||
public function __invoke(Request $request, string $script): Response
|
||||
{
|
||||
$path = ModuleRegistry::scriptFor($script);
|
||||
|
||||
abort_if($path === null || ! is_file($path), 404);
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
abort_if(! is_string($contents), 404);
|
||||
|
||||
$version = ModuleAssetVersion::forContents($contents);
|
||||
$requested = $request->query('v');
|
||||
$matchesServedBytes = is_string($requested) && hash_equals($version, $requested);
|
||||
|
||||
$response = response($contents, 200, ['Content-Type' => 'application/javascript'])
|
||||
->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
|
||||
|
||||
$response->headers->set(
|
||||
'Cache-Control',
|
||||
$matchesServedBytes ? 'public, max-age=31536000, immutable' : 'no-store'
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Http\Controllers\Assets;
|
||||
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Modules\Runtime\ModuleAssetVersion;
|
||||
use DateTime;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class StyleController extends Controller
|
||||
{
|
||||
/**
|
||||
* Stream one stylesheet that a module published from its
|
||||
* ServiceProvider::boot() through
|
||||
* \InvoiceShelf\Modules\Registry::registerStyle($name, $path).
|
||||
*
|
||||
* A "v" query value equal to the hash of the bytes being served earns a
|
||||
* year-long immutable cache; every other request is answered no-store so a
|
||||
* rebuilt asset is never hidden behind a stale copy.
|
||||
*
|
||||
* @throws NotFoundHttpException
|
||||
*/
|
||||
public function __invoke(Request $request, string $style): Response
|
||||
{
|
||||
$path = ModuleRegistry::styleFor($style);
|
||||
|
||||
abort_if($path === null || ! is_file($path), 404);
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
abort_if(! is_string($contents), 404);
|
||||
|
||||
$version = ModuleAssetVersion::forContents($contents);
|
||||
$requested = $request->query('v');
|
||||
$matchesServedBytes = is_string($requested) && hash_equals($version, $requested);
|
||||
|
||||
$response = response($contents, 200, ['Content-Type' => 'text/css'])
|
||||
->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
|
||||
|
||||
$response->headers->set(
|
||||
'Cache-Control',
|
||||
$matchesServedBytes ? 'public, max-age=31536000, immutable' : 'no-store'
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UploadModuleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'avatar' => ['required', 'file', 'mimes:zip', 'max:20000'],
|
||||
'module' => ['required', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Http\Resources;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ModuleCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Hand back the default mapping over the wrapped module resources.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return array|Arrayable|\JsonSerializable
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$modules = parent::toArray($request);
|
||||
|
||||
return $modules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Registry row describing one module known to this installation.
|
||||
*/
|
||||
class Module extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'modules';
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'installed' => 'boolean',
|
||||
'enabled' => 'boolean',
|
||||
'last_failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Runtime;
|
||||
|
||||
use App\Platform\Modules\Events\ModuleEnabledEvent;
|
||||
use App\Platform\Modules\Events\ModuleInstalledEvent;
|
||||
use App\Platform\Modules\Models\Module as ModelsModule;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
|
||||
class ModuleInstaller
|
||||
{
|
||||
/**
|
||||
* Migrate and activate a module already present on disk, write its registry
|
||||
* row, then announce the install and the activation.
|
||||
*/
|
||||
public static function complete($module, $version): bool
|
||||
{
|
||||
Module::register();
|
||||
|
||||
Artisan::call(sprintf('module:migrate %s --force', $module));
|
||||
Artisan::call(sprintf('module:enable %s', $module));
|
||||
|
||||
$record = ModelsModule::updateOrCreate(
|
||||
['name' => $module],
|
||||
['version' => $version, 'installed' => true, 'enabled' => true]
|
||||
);
|
||||
|
||||
event(new ModuleInstalledEvent($record));
|
||||
event(new ModuleEnabledEvent($record));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Platform\Operations\Installation\Application\InstallationState;
|
||||
use App\Platform\Persistence\ModelIdentityMap;
|
||||
use App\Support\Bouncer\BouncerDefaultScope;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Silber\Bouncer\Database\Models as BouncerModels;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Where a signed-in staff user lands.
|
||||
*
|
||||
* The authentication layer redirects here once credentials check out.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const HOME = '/admin/dashboard';
|
||||
|
||||
/**
|
||||
* Where a signed-in portal customer lands.
|
||||
*
|
||||
* The customer guard redirects here once credentials check out.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CUSTOMER_HOME = '/customer/dashboard';
|
||||
|
||||
/**
|
||||
* Boot the application-wide behaviour.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
ModelIdentityMap::enforce();
|
||||
|
||||
Factory::guessFactoryNamesUsing(
|
||||
fn (string $model): string => 'Database\\Factories\\'.class_basename($model).'Factory'
|
||||
);
|
||||
|
||||
// Navigation is built from config only once there is a schema to talk
|
||||
// to; during a fresh install the tables do not exist yet.
|
||||
if (InstallationState::isDbCreated()) {
|
||||
$this->addMenus();
|
||||
}
|
||||
|
||||
$this->bootBroadcast();
|
||||
|
||||
// The public demo build must never put real mail on the wire.
|
||||
if (config('app.env') === 'demo') {
|
||||
Mail::fake();
|
||||
Notification::fake();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register container bindings.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
BouncerModels::scope(new BouncerDefaultScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish every navigation tree the SPA can ask for.
|
||||
*
|
||||
* Keys are the registered menu names; values are the config entries each
|
||||
* one is built from. Note the customer portal menu is registered under a
|
||||
* name that differs from its config key.
|
||||
*/
|
||||
public function addMenus()
|
||||
{
|
||||
$sources = [
|
||||
'main_menu' => 'invoiceshelf.main_menu',
|
||||
'admin_menu' => 'invoiceshelf.admin_menu',
|
||||
'setting_menu' => 'invoiceshelf.setting_menu',
|
||||
'customer_portal_menu' => 'invoiceshelf.customer_menu',
|
||||
];
|
||||
|
||||
foreach ($sources as $name => $configKey) {
|
||||
\Menu::make($name, function ($menu) use ($configKey) {
|
||||
foreach (config($configKey) as $data) {
|
||||
$this->generateMenu($menu, $data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one configured entry to a menu under construction.
|
||||
*
|
||||
* Everything past the title and link rides along as item metadata, which
|
||||
* is what the bootstrap endpoints filter and hand to the frontend.
|
||||
*/
|
||||
public function generateMenu($menu, $data)
|
||||
{
|
||||
$item = $menu->add($data['title'], $data['link']);
|
||||
|
||||
$meta = [
|
||||
'icon' => $data['icon'],
|
||||
'name' => $data['name'],
|
||||
'owner_only' => $data['owner_only'],
|
||||
'super_admin_only' => $data['super_admin_only'] ?? false,
|
||||
'ability' => $data['ability'],
|
||||
'model' => $data['model'],
|
||||
'group' => $data['group'],
|
||||
'group_label' => $data['group_label'] ?? '',
|
||||
'priority' => $data['priority'] ?? 100,
|
||||
];
|
||||
|
||||
foreach ($meta as $key => $value) {
|
||||
$item->data($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the broadcasting auth endpoint behind the API guard.
|
||||
*/
|
||||
public function bootBroadcast()
|
||||
{
|
||||
Broadcast::routes(['middleware' => 'api.auth']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as FrameworkRouteServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends FrameworkRouteServiceProvider
|
||||
{
|
||||
/**
|
||||
* Where a signed-in staff user lands.
|
||||
*
|
||||
* The authentication layer redirects here once credentials check out.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const HOME = '/admin/dashboard';
|
||||
|
||||
/**
|
||||
* Where a signed-in portal customer lands.
|
||||
*
|
||||
* The customer guard redirects here once credentials check out.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CUSTOMER_HOME = '/customer/dashboard';
|
||||
|
||||
/**
|
||||
* Install the throttling rules, then mount the route files.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureRateLimiting();
|
||||
|
||||
$this->routes(function () {
|
||||
Route::prefix('api')
|
||||
->middleware('api')
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web.php'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the named rate limiters this application offers.
|
||||
*/
|
||||
protected function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ViewServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register container bindings.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand every Blade view the branding values it may render.
|
||||
*
|
||||
* Each one is shared under the same name as the application setting it
|
||||
* comes from, so the templates and the settings screen speak one
|
||||
* vocabulary.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$branding = [
|
||||
'login_page_logo',
|
||||
'login_page_heading',
|
||||
'login_page_description',
|
||||
'admin_page_title',
|
||||
'copyright_text',
|
||||
];
|
||||
|
||||
foreach ($branding as $setting) {
|
||||
View::share($setting, get_app_setting($setting));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Validates the JSON envelope the SPA posts for logo, avatar and receipt
|
||||
* uploads: an object carrying a file `name` plus a `data:` URI whose payload
|
||||
* is base64.
|
||||
*
|
||||
* Two checks run in sequence. First the declared side of the envelope (the
|
||||
* filename extension and the shape of the data URI); then the bytes are
|
||||
* decoded and sniffed. The sniff is deliberately allowed to clear a failure
|
||||
* raised by the declared side, so a payload whose real type is acceptable is
|
||||
* let through even when the filename disagrees.
|
||||
*/
|
||||
class Base64Mime implements ValidationRule
|
||||
{
|
||||
private $attribute;
|
||||
|
||||
private $extensions;
|
||||
|
||||
/**
|
||||
* @param array $extensions File extensions considered acceptable.
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(array $extensions)
|
||||
{
|
||||
$this->extensions = $extensions;
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$this->attribute = $attribute;
|
||||
$failed = false;
|
||||
|
||||
try {
|
||||
$envelope = json_decode(trim($value));
|
||||
$name = ! empty($envelope->name) ? $envelope->name : '';
|
||||
$uri = ! empty($envelope->data) ? $envelope->data : '';
|
||||
} catch (\Exception $e) {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
if (! in_array(pathinfo($name, PATHINFO_EXTENSION), $this->extensions)) {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
if (! preg_match('/^data:\w+\/[\w\+]+;base64,[\w\+\=\/]+$/', $uri)) {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
$segments = explode(',', $uri);
|
||||
|
||||
if (! isset($segments[1]) || empty($segments[1])) {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$bytes = base64_decode($segments[1]);
|
||||
$handle = finfo_open();
|
||||
$sniffed = finfo_buffer($handle, $bytes, FILEINFO_EXTENSION);
|
||||
|
||||
if ($sniffed === '???') {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
// A sniff may answer with several equivalent extensions joined by
|
||||
// slashes, e.g. "jpeg/jpg/jpe/jfif"; any one of them matching is
|
||||
// enough to accept the upload.
|
||||
if (strpos($sniffed, '/')) {
|
||||
foreach (explode('/', $sniffed) as $candidate) {
|
||||
if (in_array($candidate, $this->extensions)) {
|
||||
$failed = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (in_array($sniffed, $this->extensions)) {
|
||||
$failed = false;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
if ($failed) {
|
||||
$fail('The '.$this->attribute.' must be a json with file of type: '.implode(', ', $this->extensions).' encoded in base64.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Guards bulk deletes: rejects an id whose record still has rows hanging off
|
||||
* the named relation.
|
||||
*
|
||||
* Known defect, kept deliberately: an id with no matching record makes the
|
||||
* lookup return null and the relation call raise, which surfaces as a 500
|
||||
* rather than a validation failure. Call sites pair this with an existence
|
||||
* check when they care.
|
||||
*/
|
||||
class RelationNotExist implements ValidationRule
|
||||
{
|
||||
public $class;
|
||||
|
||||
public $relation;
|
||||
|
||||
/**
|
||||
* @param string|null $class Model to look the value up on.
|
||||
* @param string|null $relation Relation method that must come back empty.
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(?string $class = null, ?string $relation = null)
|
||||
{
|
||||
$this->class = $class;
|
||||
$this->relation = $relation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the value.
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$method = $this->relation;
|
||||
|
||||
if ($this->class::find($value)->$method()->exists()) {
|
||||
$fail("Relation {$this->relation} exists.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Formatting;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* The date layouts a company may choose between.
|
||||
*
|
||||
* Every layout is held twice, as a PHP pattern for whatever the server renders
|
||||
* and as the moment.js pattern that matches it in the browser, so both sides
|
||||
* write a saved date the same way round. What the settings screen shows is
|
||||
* today's date put through the server pattern, in the company's own language.
|
||||
*/
|
||||
class DateFormatter
|
||||
{
|
||||
/**
|
||||
* The offered layouts, server pattern beside its browser counterpart.
|
||||
*
|
||||
* KNOWN QUIRK: the moment pattern for 'Y/m/d' carries a leading space. It
|
||||
* has been handed to clients that way for long enough to count as part of
|
||||
* the payload, so it is left alone.
|
||||
*
|
||||
* @var array<int, array<string, string>>
|
||||
*/
|
||||
protected static $formats = [
|
||||
['carbon_format' => 'Y M d', 'moment_format' => 'YYYY MMM DD'],
|
||||
['carbon_format' => 'd M Y', 'moment_format' => 'DD MMM YYYY'],
|
||||
['carbon_format' => 'd/m/Y', 'moment_format' => 'DD/MM/YYYY'],
|
||||
['carbon_format' => 'd.m.Y', 'moment_format' => 'DD.MM.YYYY'],
|
||||
['carbon_format' => 'd-m-Y', 'moment_format' => 'DD-MM-YYYY'],
|
||||
['carbon_format' => 'm/d/Y', 'moment_format' => 'MM/DD/YYYY'],
|
||||
['carbon_format' => 'Y/m/d', 'moment_format' => ' YYYY/MM/DD'],
|
||||
['carbon_format' => 'Y-m-d', 'moment_format' => 'YYYY-MM-DD'],
|
||||
];
|
||||
|
||||
public static function get_list()
|
||||
{
|
||||
return array_map(
|
||||
fn (array $layout) => [
|
||||
'display_date' => Carbon::now()->translatedFormat($layout['carbon_format']),
|
||||
'carbon_format_value' => $layout['carbon_format'],
|
||||
'moment_format_value' => $layout['moment_format'],
|
||||
],
|
||||
static::$formats
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Formatting;
|
||||
|
||||
/**
|
||||
* The zones a company can be set to, as the time-zone picker wants them.
|
||||
*
|
||||
* Two facts are kept per zone and nothing else: its IANA identifier, and how
|
||||
* far standard time there runs from UTC. The label is composed from the pair,
|
||||
* the offset in brackets followed by the place, and the place is read straight
|
||||
* out of the identifier. Rows run west to east, ordered on the offset with the
|
||||
* place name settling ties.
|
||||
*
|
||||
* Standard time is used deliberately, so that an entry cannot re-label itself
|
||||
* twice a year, and the table is deliberately frozen: identifiers the database
|
||||
* has since renamed are still what older companies have saved against, and
|
||||
* re-basing a zone here would silently move every one of them.
|
||||
*/
|
||||
class TimeZones
|
||||
{
|
||||
public static function get_list()
|
||||
{
|
||||
$zones = [
|
||||
'Pacific/Midway' => '-11:00',
|
||||
'Pacific/Niue' => '-11:00',
|
||||
'Pacific/Pago_Pago' => '-11:00',
|
||||
'America/Adak' => '-10:00',
|
||||
'Pacific/Honolulu' => '-10:00',
|
||||
'Pacific/Johnston' => '-10:00',
|
||||
'Pacific/Rarotonga' => '-10:00',
|
||||
'Pacific/Tahiti' => '-10:00',
|
||||
'Pacific/Marquesas' => '-09:30',
|
||||
'America/Anchorage' => '-09:00',
|
||||
'Pacific/Gambier' => '-09:00',
|
||||
'America/Juneau' => '-09:00',
|
||||
'America/Nome' => '-09:00',
|
||||
'America/Sitka' => '-09:00',
|
||||
'America/Yakutat' => '-09:00',
|
||||
'America/Dawson' => '-08:00',
|
||||
'America/Los_Angeles' => '-08:00',
|
||||
'America/Metlakatla' => '-08:00',
|
||||
'Pacific/Pitcairn' => '-08:00',
|
||||
'America/Santa_Isabel' => '-08:00',
|
||||
'America/Tijuana' => '-08:00',
|
||||
'America/Vancouver' => '-08:00',
|
||||
'America/Whitehorse' => '-08:00',
|
||||
'America/Boise' => '-07:00',
|
||||
'America/Cambridge_Bay' => '-07:00',
|
||||
'America/Chihuahua' => '-07:00',
|
||||
'America/Creston' => '-07:00',
|
||||
'America/Dawson_Creek' => '-07:00',
|
||||
'America/Denver' => '-07:00',
|
||||
'America/Edmonton' => '-07:00',
|
||||
'America/Hermosillo' => '-07:00',
|
||||
'America/Inuvik' => '-07:00',
|
||||
'America/Mazatlan' => '-07:00',
|
||||
'America/Ojinaga' => '-07:00',
|
||||
'America/Phoenix' => '-07:00',
|
||||
'America/Shiprock' => '-07:00',
|
||||
'America/Yellowknife' => '-07:00',
|
||||
'America/Bahia_Banderas' => '-06:00',
|
||||
'America/Belize' => '-06:00',
|
||||
'America/North_Dakota/Beulah' => '-06:00',
|
||||
'America/Cancun' => '-06:00',
|
||||
'America/North_Dakota/Center' => '-06:00',
|
||||
'America/Chicago' => '-06:00',
|
||||
'America/Costa_Rica' => '-06:00',
|
||||
'Pacific/Easter' => '-06:00',
|
||||
'America/El_Salvador' => '-06:00',
|
||||
'Pacific/Galapagos' => '-06:00',
|
||||
'America/Guatemala' => '-06:00',
|
||||
'America/Indiana/Knox' => '-06:00',
|
||||
'America/Managua' => '-06:00',
|
||||
'America/Matamoros' => '-06:00',
|
||||
'America/Menominee' => '-06:00',
|
||||
'America/Merida' => '-06:00',
|
||||
'America/Mexico_City' => '-06:00',
|
||||
'America/Monterrey' => '-06:00',
|
||||
'America/North_Dakota/New_Salem' => '-06:00',
|
||||
'America/Rainy_River' => '-06:00',
|
||||
'America/Rankin_Inlet' => '-06:00',
|
||||
'America/Regina' => '-06:00',
|
||||
'America/Resolute' => '-06:00',
|
||||
'America/Swift_Current' => '-06:00',
|
||||
'America/Tegucigalpa' => '-06:00',
|
||||
'America/Indiana/Tell_City' => '-06:00',
|
||||
'America/Winnipeg' => '-06:00',
|
||||
'America/Atikokan' => '-05:00',
|
||||
'America/Bogota' => '-05:00',
|
||||
'America/Cayman' => '-05:00',
|
||||
'America/Detroit' => '-05:00',
|
||||
'America/Grand_Turk' => '-05:00',
|
||||
'America/Guayaquil' => '-05:00',
|
||||
'America/Havana' => '-05:00',
|
||||
'America/Indiana/Indianapolis' => '-05:00',
|
||||
'America/Iqaluit' => '-05:00',
|
||||
'America/Jamaica' => '-05:00',
|
||||
'America/Lima' => '-05:00',
|
||||
'America/Kentucky/Louisville' => '-05:00',
|
||||
'America/Indiana/Marengo' => '-05:00',
|
||||
'America/Kentucky/Monticello' => '-05:00',
|
||||
'America/Montreal' => '-05:00',
|
||||
'America/Nassau' => '-05:00',
|
||||
'America/New_York' => '-05:00',
|
||||
'America/Nipigon' => '-05:00',
|
||||
'America/Panama' => '-05:00',
|
||||
'America/Pangnirtung' => '-05:00',
|
||||
'America/Indiana/Petersburg' => '-05:00',
|
||||
'America/Port-au-Prince' => '-05:00',
|
||||
'America/Thunder_Bay' => '-05:00',
|
||||
'America/Toronto' => '-05:00',
|
||||
'America/Indiana/Vevay' => '-05:00',
|
||||
'America/Indiana/Vincennes' => '-05:00',
|
||||
'America/Indiana/Winamac' => '-05:00',
|
||||
'America/Caracas' => '-04:30',
|
||||
'America/Anguilla' => '-04:00',
|
||||
'America/Antigua' => '-04:00',
|
||||
'America/Aruba' => '-04:00',
|
||||
'America/Asuncion' => '-04:00',
|
||||
'America/Barbados' => '-04:00',
|
||||
'Atlantic/Bermuda' => '-04:00',
|
||||
'America/Blanc-Sablon' => '-04:00',
|
||||
'America/Boa_Vista' => '-04:00',
|
||||
'America/Campo_Grande' => '-04:00',
|
||||
'America/Cuiaba' => '-04:00',
|
||||
'America/Curacao' => '-04:00',
|
||||
'America/Dominica' => '-04:00',
|
||||
'America/Eirunepe' => '-04:00',
|
||||
'America/Glace_Bay' => '-04:00',
|
||||
'America/Goose_Bay' => '-04:00',
|
||||
'America/Grenada' => '-04:00',
|
||||
'America/Guadeloupe' => '-04:00',
|
||||
'America/Guyana' => '-04:00',
|
||||
'America/Halifax' => '-04:00',
|
||||
'America/Kralendijk' => '-04:00',
|
||||
'America/La_Paz' => '-04:00',
|
||||
'America/Lower_Princes' => '-04:00',
|
||||
'America/Manaus' => '-04:00',
|
||||
'America/Marigot' => '-04:00',
|
||||
'America/Martinique' => '-04:00',
|
||||
'America/Moncton' => '-04:00',
|
||||
'America/Montserrat' => '-04:00',
|
||||
'Antarctica/Palmer' => '-04:00',
|
||||
'America/Port_of_Spain' => '-04:00',
|
||||
'America/Porto_Velho' => '-04:00',
|
||||
'America/Puerto_Rico' => '-04:00',
|
||||
'America/Rio_Branco' => '-04:00',
|
||||
'America/Santiago' => '-04:00',
|
||||
'America/Santo_Domingo' => '-04:00',
|
||||
'America/St_Barthelemy' => '-04:00',
|
||||
'America/St_Kitts' => '-04:00',
|
||||
'America/St_Lucia' => '-04:00',
|
||||
'America/St_Thomas' => '-04:00',
|
||||
'America/St_Vincent' => '-04:00',
|
||||
'America/Thule' => '-04:00',
|
||||
'America/Tortola' => '-04:00',
|
||||
'America/St_Johns' => '-03:30',
|
||||
'America/Araguaina' => '-03:00',
|
||||
'America/Bahia' => '-03:00',
|
||||
'America/Belem' => '-03:00',
|
||||
'America/Argentina/Buenos_Aires' => '-03:00',
|
||||
'America/Argentina/Catamarca' => '-03:00',
|
||||
'America/Cayenne' => '-03:00',
|
||||
'America/Argentina/Cordoba' => '-03:00',
|
||||
'America/Fortaleza' => '-03:00',
|
||||
'America/Godthab' => '-03:00',
|
||||
'America/Argentina/Jujuy' => '-03:00',
|
||||
'America/Argentina/La_Rioja' => '-03:00',
|
||||
'America/Maceio' => '-03:00',
|
||||
'America/Argentina/Mendoza' => '-03:00',
|
||||
'America/Miquelon' => '-03:00',
|
||||
'America/Montevideo' => '-03:00',
|
||||
'America/Paramaribo' => '-03:00',
|
||||
'America/Recife' => '-03:00',
|
||||
'America/Argentina/Rio_Gallegos' => '-03:00',
|
||||
'Antarctica/Rothera' => '-03:00',
|
||||
'America/Argentina/Salta' => '-03:00',
|
||||
'America/Argentina/San_Juan' => '-03:00',
|
||||
'America/Argentina/San_Luis' => '-03:00',
|
||||
'America/Santarem' => '-03:00',
|
||||
'America/Sao_Paulo' => '-03:00',
|
||||
'Atlantic/Stanley' => '-03:00',
|
||||
'America/Argentina/Tucuman' => '-03:00',
|
||||
'America/Argentina/Ushuaia' => '-03:00',
|
||||
'America/Noronha' => '-02:00',
|
||||
'Atlantic/South_Georgia' => '-02:00',
|
||||
'Atlantic/Azores' => '-01:00',
|
||||
'Atlantic/Cape_Verde' => '-01:00',
|
||||
'America/Scoresbysund' => '-01:00',
|
||||
'Africa/Abidjan' => '+00:00',
|
||||
'Africa/Accra' => '+00:00',
|
||||
'Africa/Bamako' => '+00:00',
|
||||
'Africa/Banjul' => '+00:00',
|
||||
'Africa/Bissau' => '+00:00',
|
||||
'Atlantic/Canary' => '+00:00',
|
||||
'Africa/Casablanca' => '+00:00',
|
||||
'Africa/Conakry' => '+00:00',
|
||||
'Africa/Dakar' => '+00:00',
|
||||
'America/Danmarkshavn' => '+00:00',
|
||||
'Europe/Dublin' => '+00:00',
|
||||
'Africa/El_Aaiun' => '+00:00',
|
||||
'Atlantic/Faroe' => '+00:00',
|
||||
'Africa/Freetown' => '+00:00',
|
||||
'Europe/Guernsey' => '+00:00',
|
||||
'Europe/Isle_of_Man' => '+00:00',
|
||||
'Europe/Jersey' => '+00:00',
|
||||
'Europe/Lisbon' => '+00:00',
|
||||
'Africa/Lome' => '+00:00',
|
||||
'Europe/London' => '+00:00',
|
||||
'Atlantic/Madeira' => '+00:00',
|
||||
'Africa/Monrovia' => '+00:00',
|
||||
'Africa/Nouakchott' => '+00:00',
|
||||
'Africa/Ouagadougou' => '+00:00',
|
||||
'Atlantic/Reykjavik' => '+00:00',
|
||||
'Africa/Sao_Tome' => '+00:00',
|
||||
'Atlantic/St_Helena' => '+00:00',
|
||||
'UTC' => '+00:00',
|
||||
'Africa/Algiers' => '+01:00',
|
||||
'Europe/Amsterdam' => '+01:00',
|
||||
'Europe/Andorra' => '+01:00',
|
||||
'Africa/Bangui' => '+01:00',
|
||||
'Europe/Belgrade' => '+01:00',
|
||||
'Europe/Berlin' => '+01:00',
|
||||
'Europe/Bratislava' => '+01:00',
|
||||
'Africa/Brazzaville' => '+01:00',
|
||||
'Europe/Brussels' => '+01:00',
|
||||
'Europe/Budapest' => '+01:00',
|
||||
'Europe/Busingen' => '+01:00',
|
||||
'Africa/Ceuta' => '+01:00',
|
||||
'Europe/Copenhagen' => '+01:00',
|
||||
'Africa/Douala' => '+01:00',
|
||||
'Europe/Gibraltar' => '+01:00',
|
||||
'Africa/Kinshasa' => '+01:00',
|
||||
'Africa/Lagos' => '+01:00',
|
||||
'Africa/Libreville' => '+01:00',
|
||||
'Europe/Ljubljana' => '+01:00',
|
||||
'Arctic/Longyearbyen' => '+01:00',
|
||||
'Africa/Luanda' => '+01:00',
|
||||
'Europe/Luxembourg' => '+01:00',
|
||||
'Europe/Madrid' => '+01:00',
|
||||
'Africa/Malabo' => '+01:00',
|
||||
'Europe/Malta' => '+01:00',
|
||||
'Europe/Monaco' => '+01:00',
|
||||
'Africa/Ndjamena' => '+01:00',
|
||||
'Africa/Niamey' => '+01:00',
|
||||
'Europe/Oslo' => '+01:00',
|
||||
'Europe/Paris' => '+01:00',
|
||||
'Europe/Podgorica' => '+01:00',
|
||||
'Africa/Porto-Novo' => '+01:00',
|
||||
'Europe/Prague' => '+01:00',
|
||||
'Europe/Rome' => '+01:00',
|
||||
'Europe/San_Marino' => '+01:00',
|
||||
'Europe/Sarajevo' => '+01:00',
|
||||
'Europe/Skopje' => '+01:00',
|
||||
'Europe/Stockholm' => '+01:00',
|
||||
'Europe/Tirane' => '+01:00',
|
||||
'Africa/Tripoli' => '+01:00',
|
||||
'Africa/Tunis' => '+01:00',
|
||||
'Europe/Vaduz' => '+01:00',
|
||||
'Europe/Vatican' => '+01:00',
|
||||
'Europe/Vienna' => '+01:00',
|
||||
'Europe/Warsaw' => '+01:00',
|
||||
'Africa/Windhoek' => '+01:00',
|
||||
'Europe/Zagreb' => '+01:00',
|
||||
'Europe/Zurich' => '+01:00',
|
||||
'Europe/Athens' => '+02:00',
|
||||
'Asia/Beirut' => '+02:00',
|
||||
'Africa/Blantyre' => '+02:00',
|
||||
'Europe/Bucharest' => '+02:00',
|
||||
'Africa/Bujumbura' => '+02:00',
|
||||
'Africa/Cairo' => '+02:00',
|
||||
'Europe/Chisinau' => '+02:00',
|
||||
'Asia/Damascus' => '+02:00',
|
||||
'Africa/Gaborone' => '+02:00',
|
||||
'Asia/Gaza' => '+02:00',
|
||||
'Africa/Harare' => '+02:00',
|
||||
'Asia/Hebron' => '+02:00',
|
||||
'Europe/Helsinki' => '+02:00',
|
||||
'Europe/Istanbul' => '+02:00',
|
||||
'Asia/Jerusalem' => '+02:00',
|
||||
'Africa/Johannesburg' => '+02:00',
|
||||
'Europe/Kiev' => '+02:00',
|
||||
'Africa/Kigali' => '+02:00',
|
||||
'Africa/Lubumbashi' => '+02:00',
|
||||
'Africa/Lusaka' => '+02:00',
|
||||
'Africa/Maputo' => '+02:00',
|
||||
'Europe/Mariehamn' => '+02:00',
|
||||
'Africa/Maseru' => '+02:00',
|
||||
'Africa/Mbabane' => '+02:00',
|
||||
'Asia/Nicosia' => '+02:00',
|
||||
'Europe/Riga' => '+02:00',
|
||||
'Europe/Simferopol' => '+02:00',
|
||||
'Europe/Sofia' => '+02:00',
|
||||
'Europe/Tallinn' => '+02:00',
|
||||
'Europe/Uzhgorod' => '+02:00',
|
||||
'Europe/Vilnius' => '+02:00',
|
||||
'Europe/Zaporozhye' => '+02:00',
|
||||
'Africa/Addis_Ababa' => '+03:00',
|
||||
'Asia/Aden' => '+03:00',
|
||||
'Asia/Amman' => '+03:00',
|
||||
'Indian/Antananarivo' => '+03:00',
|
||||
'Africa/Asmara' => '+03:00',
|
||||
'Asia/Baghdad' => '+03:00',
|
||||
'Asia/Bahrain' => '+03:00',
|
||||
'Indian/Comoro' => '+03:00',
|
||||
'Africa/Dar_es_Salaam' => '+03:00',
|
||||
'Africa/Djibouti' => '+03:00',
|
||||
'Africa/Juba' => '+03:00',
|
||||
'Europe/Kaliningrad' => '+03:00',
|
||||
'Africa/Kampala' => '+03:00',
|
||||
'Africa/Khartoum' => '+03:00',
|
||||
'Asia/Kuwait' => '+03:00',
|
||||
'Indian/Mayotte' => '+03:00',
|
||||
'Europe/Minsk' => '+03:00',
|
||||
'Africa/Mogadishu' => '+03:00',
|
||||
'Africa/Nairobi' => '+03:00',
|
||||
'Asia/Qatar' => '+03:00',
|
||||
'Asia/Riyadh' => '+03:00',
|
||||
'Antarctica/Syowa' => '+03:00',
|
||||
'Asia/Tehran' => '+03:30',
|
||||
'Asia/Baku' => '+04:00',
|
||||
'Asia/Dubai' => '+04:00',
|
||||
'Indian/Mahe' => '+04:00',
|
||||
'Indian/Mauritius' => '+04:00',
|
||||
'Europe/Moscow' => '+04:00',
|
||||
'Asia/Muscat' => '+04:00',
|
||||
'Indian/Reunion' => '+04:00',
|
||||
'Europe/Samara' => '+04:00',
|
||||
'Asia/Tbilisi' => '+04:00',
|
||||
'Europe/Volgograd' => '+04:00',
|
||||
'Asia/Yerevan' => '+04:00',
|
||||
'Asia/Kabul' => '+04:30',
|
||||
'Asia/Aqtau' => '+05:00',
|
||||
'Asia/Aqtobe' => '+05:00',
|
||||
'Asia/Ashgabat' => '+05:00',
|
||||
'Asia/Dushanbe' => '+05:00',
|
||||
'Asia/Karachi' => '+05:00',
|
||||
'Indian/Kerguelen' => '+05:00',
|
||||
'Indian/Maldives' => '+05:00',
|
||||
'Antarctica/Mawson' => '+05:00',
|
||||
'Asia/Oral' => '+05:00',
|
||||
'Asia/Samarkand' => '+05:00',
|
||||
'Asia/Tashkent' => '+05:00',
|
||||
'Asia/Colombo' => '+05:30',
|
||||
'Asia/Kolkata' => '+05:30',
|
||||
'Asia/Kathmandu' => '+05:45',
|
||||
'Asia/Almaty' => '+06:00',
|
||||
'Asia/Bishkek' => '+06:00',
|
||||
'Indian/Chagos' => '+06:00',
|
||||
'Asia/Dhaka' => '+06:00',
|
||||
'Asia/Qyzylorda' => '+06:00',
|
||||
'Asia/Thimphu' => '+06:00',
|
||||
'Antarctica/Vostok' => '+06:00',
|
||||
'Asia/Yekaterinburg' => '+06:00',
|
||||
'Indian/Cocos' => '+06:30',
|
||||
'Asia/Rangoon' => '+06:30',
|
||||
'Asia/Bangkok' => '+07:00',
|
||||
'Indian/Christmas' => '+07:00',
|
||||
'Antarctica/Davis' => '+07:00',
|
||||
'Asia/Ho_Chi_Minh' => '+07:00',
|
||||
'Asia/Hovd' => '+07:00',
|
||||
'Asia/Jakarta' => '+07:00',
|
||||
'Asia/Novokuznetsk' => '+07:00',
|
||||
'Asia/Novosibirsk' => '+07:00',
|
||||
'Asia/Omsk' => '+07:00',
|
||||
'Asia/Phnom_Penh' => '+07:00',
|
||||
'Asia/Pontianak' => '+07:00',
|
||||
'Asia/Vientiane' => '+07:00',
|
||||
'Asia/Brunei' => '+08:00',
|
||||
'Antarctica/Casey' => '+08:00',
|
||||
'Asia/Choibalsan' => '+08:00',
|
||||
'Asia/Chongqing' => '+08:00',
|
||||
'Asia/Harbin' => '+08:00',
|
||||
'Asia/Hong_Kong' => '+08:00',
|
||||
'Asia/Kashgar' => '+08:00',
|
||||
'Asia/Krasnoyarsk' => '+08:00',
|
||||
'Asia/Kuala_Lumpur' => '+08:00',
|
||||
'Asia/Kuching' => '+08:00',
|
||||
'Asia/Macau' => '+08:00',
|
||||
'Asia/Makassar' => '+08:00',
|
||||
'Asia/Manila' => '+08:00',
|
||||
'Australia/Perth' => '+08:00',
|
||||
'Asia/Shanghai' => '+08:00',
|
||||
'Asia/Singapore' => '+08:00',
|
||||
'Asia/Taipei' => '+08:00',
|
||||
'Asia/Ulaanbaatar' => '+08:00',
|
||||
'Asia/Urumqi' => '+08:00',
|
||||
'Australia/Eucla' => '+08:45',
|
||||
'Asia/Dili' => '+09:00',
|
||||
'Asia/Irkutsk' => '+09:00',
|
||||
'Asia/Jayapura' => '+09:00',
|
||||
'Pacific/Palau' => '+09:00',
|
||||
'Asia/Pyongyang' => '+09:00',
|
||||
'Asia/Seoul' => '+09:00',
|
||||
'Asia/Tokyo' => '+09:00',
|
||||
'Australia/Adelaide' => '+09:30',
|
||||
'Australia/Broken_Hill' => '+09:30',
|
||||
'Australia/Darwin' => '+09:30',
|
||||
'Australia/Brisbane' => '+10:00',
|
||||
'Pacific/Chuuk' => '+10:00',
|
||||
'Australia/Currie' => '+10:00',
|
||||
'Antarctica/DumontDUrville' => '+10:00',
|
||||
'Pacific/Guam' => '+10:00',
|
||||
'Australia/Hobart' => '+10:00',
|
||||
'Asia/Khandyga' => '+10:00',
|
||||
'Australia/Lindeman' => '+10:00',
|
||||
'Australia/Melbourne' => '+10:00',
|
||||
'Pacific/Port_Moresby' => '+10:00',
|
||||
'Pacific/Saipan' => '+10:00',
|
||||
'Australia/Sydney' => '+10:00',
|
||||
'Asia/Yakutsk' => '+10:00',
|
||||
'Australia/Lord_Howe' => '+10:30',
|
||||
'Pacific/Efate' => '+11:00',
|
||||
'Pacific/Guadalcanal' => '+11:00',
|
||||
'Pacific/Kosrae' => '+11:00',
|
||||
'Antarctica/Macquarie' => '+11:00',
|
||||
'Pacific/Noumea' => '+11:00',
|
||||
'Pacific/Pohnpei' => '+11:00',
|
||||
'Asia/Sakhalin' => '+11:00',
|
||||
'Asia/Ust-Nera' => '+11:00',
|
||||
'Asia/Vladivostok' => '+11:00',
|
||||
'Pacific/Norfolk' => '+11:30',
|
||||
'Asia/Anadyr' => '+12:00',
|
||||
'Pacific/Auckland' => '+12:00',
|
||||
'Pacific/Fiji' => '+12:00',
|
||||
'Pacific/Funafuti' => '+12:00',
|
||||
'Asia/Kamchatka' => '+12:00',
|
||||
'Pacific/Kwajalein' => '+12:00',
|
||||
'Asia/Magadan' => '+12:00',
|
||||
'Pacific/Majuro' => '+12:00',
|
||||
'Antarctica/McMurdo' => '+12:00',
|
||||
'Pacific/Nauru' => '+12:00',
|
||||
'Antarctica/South_Pole' => '+12:00',
|
||||
'Pacific/Tarawa' => '+12:00',
|
||||
'Pacific/Wake' => '+12:00',
|
||||
'Pacific/Wallis' => '+12:00',
|
||||
'Pacific/Chatham' => '+12:45',
|
||||
'Pacific/Apia' => '+13:00',
|
||||
'Pacific/Enderbury' => '+13:00',
|
||||
'Pacific/Fakaofo' => '+13:00',
|
||||
'Pacific/Tongatapu' => '+13:00',
|
||||
'Pacific/Kiritimati' => '+14:00',
|
||||
];
|
||||
|
||||
$list = [];
|
||||
|
||||
foreach ($zones as $zone => $offset) {
|
||||
$list[] = [
|
||||
'value' => $zone,
|
||||
'key' => sprintf('(UTC%s) %s', $offset, self::place($zone)),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* The place an identifier names: whatever follows the last slash, read
|
||||
* with spaces instead of underscores. Saints keep the stop the
|
||||
* abbreviation is written with in prose.
|
||||
*/
|
||||
private static function place(string $zone): string
|
||||
{
|
||||
$segments = explode('/', $zone);
|
||||
|
||||
return preg_replace('/^St /', 'St. ', str_replace('_', ' ', end($segments)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Media;
|
||||
|
||||
use App\Platform\Persistence\ModelIdentityMap;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
|
||||
|
||||
/**
|
||||
* Decides where an upload sits on whichever disk the media library is writing
|
||||
* to.
|
||||
*
|
||||
* Attachments of the three document kinds are pooled, one folder per kind;
|
||||
* anything else is given a folder to itself, named after the media row's own
|
||||
* id. Generated conversions and responsive variants go in a sub-folder of
|
||||
* whichever folder the original landed in.
|
||||
*
|
||||
* These layouts are an on-disk contract rather than a preference. Installs
|
||||
* already have files sitting at exactly these paths, and nothing rewrites them,
|
||||
* so renaming a folder here loses whatever is stored below it. That is why the
|
||||
* conversions sub-folder is still spelled "conversations": it is a typo, it has
|
||||
* always been the typo, and correcting it would strand every conversion ever
|
||||
* written.
|
||||
*/
|
||||
class CustomPathGenerator implements PathGenerator
|
||||
{
|
||||
public function getPath(Media $media): string
|
||||
{
|
||||
return $this->folder($media);
|
||||
}
|
||||
|
||||
public function getPathForConversions(Media $media): string
|
||||
{
|
||||
return $this->folder($media, 'conversations');
|
||||
}
|
||||
|
||||
public function getPathForResponsiveImages(Media $media): string
|
||||
{
|
||||
return $this->folder($media, 'responsive-images');
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder shared by everything belonging to the given media.
|
||||
*
|
||||
* The fallback hands back the row's key as it comes, an integer, and lets
|
||||
* the declared return type do the widening. A media row without a key
|
||||
* therefore fails here rather than filing itself under an empty name,
|
||||
* which is the behaviour that has always been in place.
|
||||
*/
|
||||
protected function getBasePath(Media $media): string
|
||||
{
|
||||
return match ($media->model_type) {
|
||||
ModelIdentityMap::INVOICE_ALIAS => 'Invoices',
|
||||
ModelIdentityMap::ESTIMATE_ALIAS => 'Estimates',
|
||||
ModelIdentityMap::PAYMENT_ALIAS => 'Payments',
|
||||
default => $media->getKey(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* That folder, or one named sub-folder of it, closed off with the
|
||||
* separator the media library expects to find at the end.
|
||||
*/
|
||||
private function folder(Media $media, ?string $subFolder = null): string
|
||||
{
|
||||
$path = $this->getBasePath($media).'/';
|
||||
|
||||
return $subFolder === null ? $path : $path.$subFolder.'/';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user