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:
denkfabrik-li
2026-08-28 02:56:53 +02:00
parent 06c364d29a
commit e1cd010f9d
3 changed files with 78 additions and 2 deletions
@@ -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);
}
}
+1 -1
View File
@@ -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"
+41
View File
@@ -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.