diff --git a/app/Modules/Files/Http/Controllers/Api/FilesController.php b/app/Modules/Files/Http/Controllers/Api/FilesController.php index 0f4a083f..3e3d7c43 100644 --- a/app/Modules/Files/Http/Controllers/Api/FilesController.php +++ b/app/Modules/Files/Http/Controllers/Api/FilesController.php @@ -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); + } } diff --git a/docs/api/openapi.json b/docs/api/openapi.json index aa87ab86..f35c37d1 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -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" diff --git a/tests/Feature/Api/FilesWriteTest.php b/tests/Feature/Api/FilesWriteTest.php index a9663053..b2d5088a 100644 --- a/tests/Feature/Api/FilesWriteTest.php +++ b/tests/Feature/Api/FilesWriteTest.php @@ -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.