mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
Give an API expiry date the same meaning the web gives it
FilesController::expiryInstant exists because a calendar day ends where the person naming it lives: the web form posts a bare YYYY-MM-DD, and storing that as it arrives would cut a file off at midnight UTC -- "expires on the 12th" ending partway through the 11th for anyone in the Americas. The API takes the same field, validates it as a date, and stores it raw: web → 2026-09-12T23:59:59+00:00 (end of the day, as the docblock means) API → 2026-09-12T00:00:00+00:00 (raw) Same value, same field, same file, two meanings -- and the earlier of the two is a file that dies at the start of the day it was promised. A bare date now means the end of that day in the caller's timezone, as it does on the web. A value carrying a time is unchanged: it is an instant the caller named on purpose, the API can express one and a date input cannot. The endpoint's docblock says both, so the OpenAPI document does too. Three tests: the day, the timestamp, and clearing. Without the fix the first goes red.
This commit is contained in:
@@ -21,9 +21,12 @@ use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -59,6 +62,7 @@ class FilesController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -263,6 +267,12 @@ class FilesController extends Controller
|
||||
* without the matching permission leaves that field untouched rather
|
||||
* than failing the whole request, which mirrors the web interface.
|
||||
*
|
||||
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
|
||||
* timestamp. A day means the end of that day in the caller's timezone,
|
||||
* which is what the same value means on the web and what the file's
|
||||
* own `expires_at` reads back as; a timestamp is taken as the instant
|
||||
* it names.
|
||||
*
|
||||
* `commentable` only has an effect while the installation's comment
|
||||
* setting is "only files marked as commentable"; under any other
|
||||
* setting it is ignored, again rather than failing.
|
||||
@@ -306,7 +316,7 @@ class FilesController extends Controller
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||
|
||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||
$attributes['expires_at'] = $validated['expires_at'];
|
||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user);
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
||||
@@ -356,4 +366,29 @@ class FilesController extends Controller
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an `expires_at` value means.
|
||||
*
|
||||
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
|
||||
* the person naming it lives — the same rule the web form's date input
|
||||
* gets from FilesController::expiryInstant. Stored as it arrives it
|
||||
* would be midnight UTC instead, so a file asked to expire on the 12th
|
||||
* would die at the *start* of the 12th, and for a caller west of
|
||||
* Greenwich partway through the 11th.
|
||||
*
|
||||
* Anything carrying a time is an instant the caller named on purpose
|
||||
* and is stored as it arrives, unchanged from before: the API can
|
||||
* express a moment, and a date input cannot.
|
||||
*/
|
||||
private function expiryInstant(?string $value, User $setter): ?Carbon
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||
: Carbon::parse($value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2076,7 +2076,7 @@
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "files.update",
|
||||
"description": "Only the fields present in the request are changed; omitting one\nleaves it as it was.\n\nSome fields need a permission of their own \u2014 `expires_at` needs\n`set_file_expiration_date`, `public` needs `upload_public`, and\n`categories` needs `set_file_categories`. Sending one of those\nwithout the matching permission leaves that field untouched rather\nthan failing the whole request, which mirrors the web interface.\n\n`commentable` only has an effect while the installation's comment\nsetting is \"only files marked as commentable\"; under any other\nsetting it is ignored, again rather than failing.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.",
|
||||
"description": "Only the fields present in the request are changed; omitting one\nleaves it as it was.\n\nSome fields need a permission of their own \u2014 `expires_at` needs\n`set_file_expiration_date`, `public` needs `upload_public`, and\n`categories` needs `set_file_categories`. Sending one of those\nwithout the matching permission leaves that field untouched rather\nthan failing the whole request, which mirrors the web interface.\n\n`expires_at` accepts either a calendar day (`2026-09-12`) or a full\ntimestamp. A day means the end of that day in the caller's timezone,\nwhich is what the same value means on the web and what the file's\nown `expires_at` reads back as; a timestamp is taken as the instant\nit names.\n\n`commentable` only has an effect while the installation's comment\nsetting is \"only files marked as commentable\"; under any other\nsetting it is ignored, again rather than failing.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.",
|
||||
"summary": "Update a file's metadata",
|
||||
"tags": [
|
||||
"Files"
|
||||
|
||||
@@ -124,6 +124,47 @@ test('a file can be updated field by field', function () {
|
||||
->and($file->description)->toBe($file->getOriginal('description'));
|
||||
});
|
||||
|
||||
test('a calendar day means the end of that day where the caller lives', function () {
|
||||
// The same value on the web means the end of the 12th (LocalDay::end via
|
||||
// FilesController::expiryInstant). Stored as it arrives it is midnight
|
||||
// UTC, so the file would die at the *start* of the 12th instead.
|
||||
$this->admin->update(['timezone' => 'Europe/Berlin']);
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
||||
'expires_at' => '2026-09-12',
|
||||
])->assertOk();
|
||||
|
||||
// 23:59:59 on the 12th in Berlin is 21:59:59Z.
|
||||
expect($file->refresh()->expires_at?->toIso8601String())->toBe('2026-09-12T21:59:59+00:00');
|
||||
});
|
||||
|
||||
test('a timestamp is stored as the instant it names', function () {
|
||||
// The half that must not change: an API caller can name a moment, and
|
||||
// naming one is not the same as naming a day.
|
||||
$this->admin->update(['timezone' => 'Europe/Berlin']);
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
||||
'expires_at' => '2026-09-12T08:30:00+00:00',
|
||||
])->assertOk();
|
||||
|
||||
expect($file->refresh()->expires_at?->toIso8601String())->toBe('2026-09-12T08:30:00+00:00');
|
||||
});
|
||||
|
||||
test('clearing the expiry still clears it', function () {
|
||||
$file = File::factory()->create([
|
||||
'uploaded_by' => $this->admin->id,
|
||||
'expires_at' => now()->addWeek(),
|
||||
]);
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
||||
'expires_at' => null,
|
||||
])->assertOk();
|
||||
|
||||
expect($file->refresh()->expires_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('fields the caller lacks permission for are left alone rather than refused', function () {
|
||||
// Mirrors the web controller: a user who may edit a file but not set
|
||||
// expiry dates still gets to rename it.
|
||||
|
||||
Reference in New Issue
Block a user