mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-11 22:38:54 +00:00
Give a deleted folder's name back
Delete a folder called Test and you could never have a folder called Test again. The deletion worked, the folder left the screen, and the name went with it — permanently, with an error that named a collision against a row the interface will not show you and offered nothing to do about it. Files and groups had it too. All three carry a unique index on slug and all three soft-delete, so the trashed row sat in the index holding a name nothing could reach. A public one failed outright at the validator, which checks the table and therefore sees rows the screen does not. A private one failed more quietly: the derived slug stepped around the trashed row into report-2, then report-3, once per deletion, climbing forever. The reservation was deliberate — a trashed row's slug was kept so that restoring it could not land on somebody else's URL. But nothing in this application restores anything. There is no restore() call, no route, no screen; File's own comment says as much. Soft deletes are here so rows can outlive their delete for foreign keys, the activity log and the erasure grace period, never so they can come back. The slug was being held for a page that could not return, and route binding already 404s the trashed row in the meantime. So deleting now hands the slug back, and the database is what makes that a rewrite rather than a gentler lookup: teaching the collision checks to skip trashed rows would leave two rows holding "report", which the unique index rejects whatever the application thinks. The slug moves to report__deleted-42 instead. Underscores are the whole trick — Str::slug() turns them into hyphens and Rules::slug() refuses them outright, so no derived slug and no hand-typed one can ever land on a vacated one. That is a guarantee about the character class rather than a hope about collisions. The format lives in VacatedSlug rather than on the trait because the migration needs it too and a trait constant cannot be reached through the trait's own name — the first version of this was a fatal error waiting for whoever ran migrations. The migration matters as much as the hook: without it the fix only helps installations that have never deleted anything, and every name already buried stays buried. The collision checks still count trashed rows. It costs nothing and keeps them honest about what the index will accept if a row is ever soft-deleted by something that bypasses model events. previous_file_id had this same bug and was fixed this same way, in File::detachOnDelete — a trashed row holding its predecessor's unique slot so the chain could never be re-linked. This is that fix, for the other four unique indexes' worth of the same mistake. users.email is the one left, and is deliberately not in here: an email address is a login identity rather than a URL handle, and freeing it silently is the wrong answer. Fixes #1645 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,11 @@ a version is cut.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A deleted folder no longer takes its name with it.** Deleting a folder, file or group left the
|
||||
name reserved for good: creating another one with that name failed with *"The slug has already
|
||||
been taken"*, naming a conflict with a row the interface will not show you, and there was no way
|
||||
to release it from any screen. Deleting now hands the name back. Names already held by things you
|
||||
deleted earlier are released when you update. ([#1645](https://github.com/projectsend/projectsend/issues/1645))
|
||||
- **The Legacy migration tool installs with the command the guide gives you.** `composer require
|
||||
projectsend/v1-migration-tool` failed with *Could not find a matching version of package* on a
|
||||
fresh installation, because the tool is not published on Packagist and nothing told Composer
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Concerns;
|
||||
|
||||
use App\Support\VacatedSlug;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -16,9 +17,30 @@ use Illuminate\Support\Str;
|
||||
* create one without going through a form that requires it — so fall back to
|
||||
* deriving one from the name rather than failing at the database.
|
||||
*
|
||||
* Soft-deleted rows still count as collisions: their slugs remain reachable
|
||||
* until the row is really gone, and reusing one would resurrect the wrong
|
||||
* URL.
|
||||
* Deleting a row hands its slug back. A soft-deleted row is unreachable —
|
||||
* every public lookup goes through Eloquent, so the soft-delete scope has
|
||||
* already excluded it — and nothing in the application can restore one, so
|
||||
* a slug it kept holding would be reserved for a page that can never return.
|
||||
* Holding one is what burned the name of any deleted folder for good: the
|
||||
* unique index still saw the row, so the name could never be used again and
|
||||
* the screen could not say why, because the row it collided with is one the
|
||||
* interface will not show.
|
||||
*
|
||||
* The database is what forces the vacating to be a rewrite rather than a
|
||||
* softer lookup. Teaching the collision checks to ignore trashed rows is not
|
||||
* enough on its own — two rows would then both hold `report`, and the unique
|
||||
* index rejects that however the application feels about it. So the slug
|
||||
* moves into a namespace nothing else can occupy: `report__deleted-42`.
|
||||
* Underscores are the whole trick. Str::slug() turns them into hyphens and
|
||||
* Rules::slug() refuses them outright, so neither a derived slug nor a
|
||||
* hand-typed one can ever land on a vacated slug, whatever the row is called.
|
||||
*
|
||||
* The collision checks still count trashed rows anyway. It costs nothing, and
|
||||
* it keeps them honest about what the index will actually accept if a row is
|
||||
* ever soft-deleted by something that bypasses model events.
|
||||
*
|
||||
* Requires SoftDeletes: the collision check reaches for withTrashed(), and
|
||||
* vacating is only meaningful for a row that outlives its own delete.
|
||||
*
|
||||
* @phpstan-require-extends Model
|
||||
*/
|
||||
@@ -35,6 +57,10 @@ trait HasUniqueSlug
|
||||
$model->slug = static::uniqueSlugFrom($model->name);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function (self $model): void {
|
||||
static::vacateSlug($model);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +86,39 @@ trait HasUniqueSlug
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a soft-deleted row's slug back to the names still in use.
|
||||
*
|
||||
* Written through the query builder on purpose: this is bookkeeping, not
|
||||
* an edit somebody made, so it must not move updated_at or fire a second
|
||||
* round of model events on a row that is already on its way out.
|
||||
*/
|
||||
protected static function vacateSlug(self $model): void
|
||||
{
|
||||
// A row that is really gone took its slug with it. `deleted` fires
|
||||
// for forceDelete() too, and there is nothing left to rewrite.
|
||||
if ($model->isForceDeleting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($model->slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$vacated = VacatedSlug::for($model->slug, $model->getKey());
|
||||
|
||||
if ($vacated === $model->slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::query()->withTrashed()->whereKey($model->getKey())->toBase()->update(['slug' => $vacated]);
|
||||
|
||||
// Keep the in-memory row telling the truth for whatever the caller
|
||||
// does with it after the delete returns.
|
||||
$model->slug = $vacated;
|
||||
$model->syncOriginalAttribute('slug');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in when the name slugs to nothing at all — a name of only
|
||||
* punctuation or of characters Str::slug() drops entirely.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Support\Concerns\HasUniqueSlug;
|
||||
|
||||
/**
|
||||
* The slug a deleted row holds, so the name it used to have goes back into
|
||||
* circulation.
|
||||
*
|
||||
* Lives here rather than on HasUniqueSlug because the migration that vacates
|
||||
* slugs deleted before the behaviour existed needs the identical format, and
|
||||
* a trait constant cannot be reached through the trait's own name. Two copies
|
||||
* of this format that drift apart would leave rows the application no longer
|
||||
* recognises as vacated, so there is one.
|
||||
*
|
||||
* @see HasUniqueSlug
|
||||
*/
|
||||
final class VacatedSlug
|
||||
{
|
||||
/**
|
||||
* Underscores are the whole trick: Str::slug() turns them into hyphens and
|
||||
* Rules::slug() refuses them outright, so no derived slug and no
|
||||
* hand-typed one can ever collide with a vacated one.
|
||||
*/
|
||||
public const MARKER = '__deleted-';
|
||||
|
||||
/**
|
||||
* Column width. Slugs are ASCII by construction, but the truncation is
|
||||
* done in characters so a hand-written row cannot cut a byte in half.
|
||||
*/
|
||||
private const MAX_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* @param int|string $key the row's own id, so two deleted rows that
|
||||
* shared a name do not collide with each other
|
||||
*/
|
||||
public static function for(string $slug, int|string $key): string
|
||||
{
|
||||
if (self::isVacated($slug)) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$suffix = self::MARKER.$key;
|
||||
|
||||
return mb_substr($slug, 0, self::MAX_LENGTH - mb_strlen($suffix)).$suffix;
|
||||
}
|
||||
|
||||
public static function isVacated(string $slug): bool
|
||||
{
|
||||
return str_contains($slug, self::MARKER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\VacatedSlug;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Hands back the slugs held by rows deleted before slugs were vacated on
|
||||
* delete.
|
||||
*
|
||||
* Without this the fix only helps installations that have never deleted
|
||||
* anything: every folder, file and group already in the trash goes on
|
||||
* occupying its name, which is exactly the state being reported.
|
||||
*
|
||||
* Rewriting a deleted row's slug changes nothing anybody can see. A trashed
|
||||
* row is excluded from every public lookup by the soft-delete scope, so its
|
||||
* slug addresses no page — it was only ever visible as the reason a new row
|
||||
* could not have the name.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['files', 'folders', 'groups'] as $table) {
|
||||
DB::table($table)
|
||||
->whereNotNull('deleted_at')
|
||||
->whereNotNull('slug')
|
||||
->where('slug', 'not like', '%'.VacatedSlug::MARKER.'%')
|
||||
// Chunked because an installation that has been running a
|
||||
// while can have a lot of these, and each row's new slug
|
||||
// depends on its own id rather than on anything set-wide.
|
||||
->orderBy('id')
|
||||
->chunkById(500, function (Collection $rows) use ($table): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table($table)
|
||||
->where('id', $row->id)
|
||||
->update(['slug' => VacatedSlug::for((string) $row->slug, (int) $row->id)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately does not put the old slugs back. By the time this is rolled
|
||||
* back the names may well have been taken by live rows, and restoring them
|
||||
* would break the unique index this migration exists to stop tripping over.
|
||||
* A deleted row keeping a vacated slug harms nothing on older code — it
|
||||
* reads as an unusual name on a row nobody can see.
|
||||
*/
|
||||
public function down(): void {}
|
||||
};
|
||||
@@ -197,6 +197,26 @@ test('folder ownership splits own from others for edit and delete', function ()
|
||||
$this->delete("/folders/{$others->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
// Issue #1645, end to end through the screen the report came from: a deleted
|
||||
// folder used to keep its public URL, so the name could never be used again
|
||||
// and the error named a row the interface will not show.
|
||||
test('a deleted folder gives its name and its public URL back', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$this->post('/folders', ['name' => 'Quarterly', 'public' => true, 'slug' => 'quarterly'])
|
||||
->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
$first = Folder::query()->where('name', 'Quarterly')->sole();
|
||||
$this->delete("/folders/{$first->id}")->assertRedirect();
|
||||
|
||||
$this->post('/folders', ['name' => 'Quarterly', 'public' => true, 'slug' => 'quarterly'])
|
||||
->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
$second = Folder::query()->where('name', 'Quarterly')->sole();
|
||||
expect($second->id)->not->toBe($first->id)
|
||||
->and($second->slug)->toBe('quarterly');
|
||||
});
|
||||
|
||||
test('creating folders requires create_own_folders', function () {
|
||||
// Account Manager lacks create_own_folders in the v1 default set.
|
||||
$manager = User::factory()->role(SystemRole::AccountManager)->create();
|
||||
|
||||
@@ -6,7 +6,11 @@ use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Support\Rules;
|
||||
use App\Support\VacatedSlug;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
@@ -34,12 +38,93 @@ test('colliding names get a numeric suffix that keeps counting up', function ()
|
||||
->and(File::factory()->create(['name' => 'Report'])->slug)->toBe('report-3');
|
||||
});
|
||||
|
||||
test('a soft-deleted row still collides, so its URL is not silently reused', function () {
|
||||
// Deleting used to hold the slug forever, which burned the name: nothing can
|
||||
// restore a trashed row, so it was reserved for a page that could never come
|
||||
// back. See issue #1645.
|
||||
test('deleting a row hands its slug back', function () {
|
||||
$first = File::factory()->create(['name' => 'Report']);
|
||||
$first->delete();
|
||||
|
||||
expect($first->trashed())->toBeTrue()
|
||||
->and(File::factory()->create(['name' => 'Report'])->slug)->toBe('report-2');
|
||||
->and(File::factory()->create(['name' => 'Report'])->slug)->toBe('report');
|
||||
});
|
||||
|
||||
test('the vacated slug is parked where nothing can collide with it', function () {
|
||||
$file = File::factory()->create(['name' => 'Report']);
|
||||
$file->delete();
|
||||
|
||||
$parked = File::withTrashed()->whereKey($file->id)->value('slug');
|
||||
|
||||
expect($parked)->toBe('report'.VacatedSlug::MARKER.$file->id)
|
||||
// Neither route into a slug can produce that string, which is what
|
||||
// makes parking there safe rather than merely unlikely.
|
||||
->and(Str::slug($parked))->not->toContain('_')
|
||||
->and(Validator::make(
|
||||
['slug' => $parked, 'public' => true],
|
||||
['slug' => Rules::slug('files')],
|
||||
)->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the name is reusable however many times it is deleted', function () {
|
||||
foreach (range(1, 3) as $ignored) {
|
||||
$file = File::factory()->create(['name' => 'Report']);
|
||||
expect($file->slug)->toBe('report');
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
// Three deleted rows, each parked under its own id rather than piling up
|
||||
// as report-2, report-3, report-4 the way the suffix used to.
|
||||
expect(File::withTrashed()->where('slug', 'like', 'report'.VacatedSlug::MARKER.'%')->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('all three models hand the slug back', function () {
|
||||
$file = File::factory()->create(['name' => 'Shared']);
|
||||
$folder = Folder::query()->create(['name' => 'Shared', 'path' => '/']);
|
||||
$group = Group::query()->create(['name' => 'Shared']);
|
||||
|
||||
$file->delete();
|
||||
$folder->delete();
|
||||
$group->delete();
|
||||
|
||||
expect(File::factory()->create(['name' => 'Shared'])->slug)->toBe('shared')
|
||||
->and(Folder::query()->create(['name' => 'Shared', 'path' => '/'])->slug)->toBe('shared')
|
||||
->and(Group::query()->create(['name' => 'Shared'])->slug)->toBe('shared');
|
||||
});
|
||||
|
||||
// The reported bug: a public folder's slug is user-supplied and validated
|
||||
// against the table, so a trashed row holding it made the name unusable with
|
||||
// an error naming a row the interface will not show.
|
||||
test('a deleted public row does not block its slug at the validator', function () {
|
||||
$folder = Folder::query()->create(['name' => 'Quarterly', 'path' => '/', 'public' => true, 'slug' => 'quarterly']);
|
||||
$folder->delete();
|
||||
|
||||
expect(Validator::make(
|
||||
['slug' => 'quarterly', 'public' => true],
|
||||
['slug' => Rules::slug('folders')],
|
||||
)->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('force-deleting leaves nothing behind to vacate', function () {
|
||||
$file = File::factory()->create(['name' => 'Report']);
|
||||
$file->forceDelete();
|
||||
|
||||
expect(File::withTrashed()->whereKey($file->id)->exists())->toBeFalse()
|
||||
->and(File::factory()->create(['name' => 'Report'])->slug)->toBe('report');
|
||||
});
|
||||
|
||||
test('vacating does not look like somebody edited the row', function () {
|
||||
$file = File::factory()->create(['name' => 'Report']);
|
||||
|
||||
$this->travel(5)->minutes();
|
||||
$file->delete();
|
||||
|
||||
// The soft delete moves updated_at itself; what matters is that vacating
|
||||
// the slug afterwards is not a second write on top of it, so the two
|
||||
// stamps the delete wrote still agree.
|
||||
$row = File::withTrashed()->whereKey($file->id)->sole();
|
||||
|
||||
expect($row->slug)->toBe('report'.VacatedSlug::MARKER.$file->id)
|
||||
->and($row->updated_at)->toEqual($row->deleted_at);
|
||||
});
|
||||
|
||||
test('a name that slugs to nothing falls back to a per-model default', function () {
|
||||
|
||||
Reference in New Issue
Block a user