Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions app/Actions/Asset/EnsureAssetFolderPaths.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php

namespace App\Actions\Asset;

use App\Models\Management\Space;
use App\Models\Space\AssetFolder;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Normalizer;

/**
* Resolves slash-separated folder paths into asset folders under a parent,
* creating what is missing. Names are compared after Unicode NFC normalization
* and case folding (in PHP, so MySQL and sqlite behave the same), soft-deleted
* folders are ignored and never restored.
*
* The transaction makes the whole resolution atomic: a failure halfway through
* leaves no partial tree. It does not provide mutual exclusion. There is no
* unique index on `(parent_id, name)` and the child lookup is an unlocked read,
* so two callers resolving `Brand/Logos` at the same instant could each insert
* their own `Brand`. A per-space cache lock serializes calls to this action,
* which closes that window for the drop path. It does not cover a plain folder
* create through the normal UI racing an ensure-paths call; that one still
* needs the unique index we do not have.
*
* @phpstan-type EnsureResult array{
* paths: array<string, string|null>,
* folders: Collection<int, AssetFolder>,
* renamed: list<array{from: string, to: string}>,
* }
*/
class EnsureAssetFolderPaths
{
/** Mirrors `asset_folders.name` being varchar(100). */
private const NAME_MAX_LENGTH = 100;

/**
* Long enough to outlast the work it guards, short enough to expire well
* before anything else gives up on the request.
*
* `EnsureAssetFolderPathsRequest::MAX_SEGMENTS` caps a payload at 2000
* folder levels, measured at ~4.8 s of work when none of them exist yet.
* Even several times slower than the measurement, that finishes inside this
* TTL, so the lock cannot lapse with the transaction still open and let a
* second drop create the duplicate folder it exists to prevent. It is also
* short of the 59 s
* `max_execution_time`: if a request is ever killed mid-transaction and the
* release in `block()` never runs, the space is blocked for at most this
* long rather than for a stretch nobody can wait out.
*/
private const LOCK_TTL_SECONDS = 30;

/**
* How long a queued caller waits. A legitimate large drop can hold the lock
* for most of the TTL, so giving up after a few seconds would 503 callers
* that only needed to wait their turn. Waiting plus the caller's own work
* still fits inside `max_execution_time`.
*/
private const LOCK_WAIT_SECONDS = 20;

/**
* @param list<string> $paths
* @return EnsureResult
*/
public function execute(Space $space, ?string $parentId, array $paths): array
{
$lock = Cache::lock("asset-folder-paths:{$space->id}", self::LOCK_TTL_SECONDS);

try {
return $lock->block(self::LOCK_WAIT_SECONDS, fn (): array => $this->resolve($parentId, $paths));
} catch (LockTimeoutException) {
// Another drop is mirroring a tree into this space right now. Running
// anyway is what creates duplicate folders, so ask for a retry.
abort(
503,
'Another folder upload is still mirroring folders into this space. '
. 'Nothing was lost — wait for it to finish and upload again.',
['Retry-After' => (string) self::LOCK_TTL_SECONDS],
);
}
}

/**
* @param list<string> $paths
* @return EnsureResult
*/
private function resolve(?string $parentId, array $paths): array
{
return new AssetFolder()->getConnection()->transaction(function () use ($parentId, $paths): array {
$resolved = [];
$touched = collect();
$renamed = [];

/** @var array<string, string> $sanitized */
$sanitized = [];

/** @var array<string, Collection<string, AssetFolder>> $childrenByParent */
$childrenByParent = [];

foreach ($paths as $path) {
// Only genuinely empty segments (leading, trailing or doubled
// slashes) drop out. A folder literally named " " exists on
// disk, so it becomes a real folder under the placeholder name
// instead of collapsing into its parent.
$segments = array_values(array_filter(
explode('/', $path),
static fn (string $segment): bool => $segment !== '',
));

$currentParentId = $parentId;

foreach ($segments as $segment) {
$segment = $this->normalize($segment);
$name = $this->sanitizeSegment($segment, $sanitized);

if ($name !== $segment) {
$renamed[$segment] = $name;
}

$cacheKey = $currentParentId ?? '';
$children = $childrenByParent[$cacheKey] ??= AssetFolder::query()
->where('parent_id', $currentParentId)
->get()
->keyBy(fn (AssetFolder $folder): string => $this->foldKey($folder->name ?? ''));

$folder = $children->get($this->foldKey($name));

if (!$folder) {
$folder = new AssetFolder([
'name' => $name,
'parent_id' => $currentParentId,
]);
$folder->save();

$childrenByParent[$cacheKey]->put($this->foldKey($name), $folder);
}

$touched->put($folder->id, $folder);
$currentParentId = $folder->id;
}

$resolved[$path] = $currentParentId;
}

return [
'paths' => $resolved,
'folders' => $touched->values(),
'renamed' => collect($renamed)
->map(static fn (string $to, string $from): array => ['from' => $from, 'to' => $to])
->values()
->all(),
];
});
}

/**
* Runs a segment through the model's own name purification, then trims,
* truncates to the column length and falls back to a placeholder when
* purification leaves nothing.
*
* Reads the raw attribute rather than `$probe->name`: `name` purifies on
* both get and set, so the accessor would run HTMLPurifier a second time
* over text the mutator has already cleaned. The stored value is what the
* column would hold, which is exactly what this needs.
*
* Segments repeat heavily across a real tree (every path under `Brand/`
* carries `Brand`), so results are memoized for the length of one call.
*
* @param array<string, string> $memo
*/
private function sanitizeSegment(string $segment, array &$memo): string
{
if (isset($memo[$segment])) {
return $memo[$segment];
}

$probe = new AssetFolder;
$probe->name = $segment;

$name = trim(mb_substr(trim((string) ($probe->getAttributes()['name'] ?? '')), 0, self::NAME_MAX_LENGTH));

return $memo[$segment] = $name === '' ? 'folder' : $name;
}

/**
* The comparison key for merging siblings: one Unicode form, one case.
* macOS hands the browser decomposed names, so an NFD "Café" from a drop
* has to find the NFC "Café" a UI create left behind.
*/
private function foldKey(string $name): string
{
return mb_strtolower($this->normalize($name));
}

/**
* NFC-normalizes when ext-intl is available. The extension is not a declared
* requirement, so without it names are compared as they arrive and a drop
* from macOS can still produce a second, visually identical folder.
*/
private function normalize(string $value): string
{
if (!class_exists(Normalizer::class)) {
return $value;
}

return Normalizer::normalize($value, Normalizer::FORM_C) ?: $value;
}
}
37 changes: 37 additions & 0 deletions app/Http/Controllers/Mgmt/EnsureAssetFolderPathsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Http\Controllers\Mgmt;

use App\Actions\Asset\EnsureAssetFolderPaths;
use App\Http\Controllers\Controller;
use App\Http\Requests\Asset\EnsureAssetFolderPathsRequest;
use App\Http\Resources\Management\AssetFolderResource;
use App\Models\Management\Space;
use Illuminate\Http\JsonResponse;

/**
* Mirrors a dropped folder tree into asset folders: every requested path is
* resolved to a folder id, creating missing folders along the way.
*/
class EnsureAssetFolderPathsController extends Controller
{
public function __invoke(
EnsureAssetFolderPathsRequest $request,
Space $space,
EnsureAssetFolderPaths $action,
): JsonResponse {
$this->authorizeSpace($space, 'asset_folders.manage');

$result = $action->execute(
$space,
$request->validated('parent_id'),
$request->validated('paths'),
);

return response()->json([
'paths' => $result['paths'],
'folders' => AssetFolderResource::collection($result['folders']),
'renamed' => $result['renamed'],
]);
}
}
104 changes: 104 additions & 0 deletions app/Http/Requests/Asset/EnsureAssetFolderPathsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace App\Http\Requests\Asset;

use App\Models\Space\AssetFolder;
use App\Rules\BoundedString;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class EnsureAssetFolderPathsRequest extends FormRequest
{
/**
* A cheap first gate on the payload size. It is not what bounds the work:
* see MAX_SEGMENTS.
*/
public const MAX_PATHS = 2000;

/**
* One path is a whole `a/b/c` chain. 1000 characters is far past anything a
* file system hands a browser and stops a single string from carrying
* thousands of segments.
*/
public const MAX_PATH_LENGTH = 1000;

/**
* The number that actually bounds a request. Every segment is a purifier
* pass and, in the worst case, a folder create with its own insert,
* broadcast and audit row. Measured at 2.4 ms per newly created folder, so a
* payload at this cap where nothing exists yet is ~4.8 s of work. That sits
* inside the action's 30 s lock TTL with room for production being several
* times slower, and far short of the 59 s `max_execution_time` that would
* kill the request mid-transaction.
*
* The array size alone cannot bound this: one 1000-character path can carry
* 500 segments, so 2000 paths could mean a million folder creates.
*/
public const MAX_SEGMENTS = 2000;

public function rules(): array
{
return [
'parent_id' => [
'nullable',
'string',
Rule::exists(new AssetFolder()->getConnectionName() . '.asset_folders', 'id')
->whereNull('deleted_at'),
],
'paths' => 'required|array|min:1|max:' . self::MAX_PATHS,
// A folder named " " is a folder the user really dropped, so a
// blank path must not be rejected. `string|min|max` cannot express
// that: Laravel skips non-implicit rules on a blank string, which
// would drop the length bound with it. See BoundedString.
'paths.*' => [new BoundedString(1, self::MAX_PATH_LENGTH)],
];
}

/**
* @return list<callable>
*/
public function after(): array
{
return [
function (Validator $validator): void {
$paths = $this->input('paths');

if (!is_array($paths)) {
return;
}

$segments = 0;

foreach ($paths as $path) {
if (is_string($path)) {
$segments += count(array_filter(
explode('/', $path),
static fn (string $segment): bool => $segment !== '',
));
}
}

if ($segments > self::MAX_SEGMENTS) {
$validator->errors()->add(
'paths',
'A single upload can mirror at most ' . self::MAX_SEGMENTS
. ' folder levels, this drop has ' . $segments . '. Split it into smaller parts.',
);
}
},
];
}

public function messages(): array
{
return [
'paths.max' => 'A single upload can mirror at most ' . self::MAX_PATHS . ' folders. Split the drop into smaller parts.',
];
}

public function authorize(): bool
{
return true;
}
}
Loading