|
|
@@ -601,27 +601,63 @@ function upload_error_message(int $code): string
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * The ordering pair the uploader sends with every file, sanitised for storage:
|
|
|
+ *
|
|
|
+ * batch opaque id shared by all files of one drop/selection
|
|
|
+ * seq the file's position within that batch
|
|
|
+ *
|
|
|
+ * Returns [] when either is absent or malformed — an upload without usable
|
|
|
+ * ordering is simply appended at the end, which is what every upload did before
|
|
|
+ * this existed, so an older cached admin.js keeps working.
|
|
|
+ *
|
|
|
+ * The batch id is never interpreted, only compared, so the guest-facing endpoint
|
|
|
+ * can accept it from an unauthenticated browser: the worst a crafted value can
|
|
|
+ * do is place the sender's own upload among its own siblings. It is still capped
|
|
|
+ * and stripped to keep the gallery JSON tidy.
|
|
|
+ */
|
|
|
+function upload_order_fields(array $fields): array
|
|
|
+{
|
|
|
+ // is_string, not a cast: a client is free to post batch[]=… as an array.
|
|
|
+ if (!is_string($fields['batch'] ?? null) || !is_numeric($fields['seq'] ?? null)) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ $batch = preg_replace('/[^A-Za-z0-9_-]+/', '', $fields['batch']) ?? '';
|
|
|
+ if ($batch === '') {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ return ['batch' => substr($batch, 0, 32), 'seq' => max(0, (int)$fields['seq'])];
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Ingest one uploaded image into a gallery: stream the original (and optional
|
|
|
- * browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
|
|
|
+ * browser-generated thumbnail) to S3, then store it in the gallery's JSON file.
|
|
|
*
|
|
|
* Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
|
|
|
- * The browser uploads several images at once, so the gallery entry is appended
|
|
|
+ * The browser uploads several images at once, so the gallery entry goes in
|
|
|
* through gallery_append_image(), which re-reads and rewrites the JSON file
|
|
|
* under an exclusive lock — two uploads finishing together cannot drop one
|
|
|
- * another's entry. Object keys are generated server-side under the gallery's
|
|
|
- * own prefix — never taken from the client.
|
|
|
+ * another's entry — and places it by the batch/seq the browser sent rather than
|
|
|
+ * at the end, so the gallery keeps the order the files were selected in.
|
|
|
+ * Object keys are generated server-side under the gallery's own prefix — never
|
|
|
+ * taken from the client.
|
|
|
*
|
|
|
* $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
|
|
|
* original must have a recognised image extension and decode via getimagesize(),
|
|
|
- * so a public link cannot be used to store arbitrary file types.
|
|
|
+ * so a public link cannot be used to store arbitrary file types. $fields is the
|
|
|
+ * request's $_POST, read for the ordering pair only.
|
|
|
*
|
|
|
* Returns [int $httpStatus, array $payload] for the caller to hand to
|
|
|
* json_response(); a thumbnail failure is non-fatal (the grid falls back to the
|
|
|
* original key).
|
|
|
*/
|
|
|
-function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb, bool $imagesOnly = false): array
|
|
|
-{
|
|
|
+function gallery_store_s3_upload(
|
|
|
+ array $gallery,
|
|
|
+ ?array $original,
|
|
|
+ ?array $thumb,
|
|
|
+ bool $imagesOnly = false,
|
|
|
+ array $fields = []
|
|
|
+): array {
|
|
|
if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
|
return [400, ['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))]];
|
|
|
}
|
|
|
@@ -665,13 +701,14 @@ function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Locked read-modify-write: concurrent uploads append without clobbering.
|
|
|
+ // Locked read-modify-write: concurrent uploads are placed in selection
|
|
|
+ // order (see gallery_image_position) without clobbering each other.
|
|
|
$count = gallery_append_image($slug, [
|
|
|
'key' => $key,
|
|
|
'thumb' => $thumbKey,
|
|
|
'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
|
|
|
'size' => (int)($original['size'] ?? 0),
|
|
|
- ]);
|
|
|
+ ] + upload_order_fields($fields));
|
|
|
|
|
|
// The gallery was deleted while this image was in flight: drop the objects
|
|
|
// we just wrote rather than leaving them unreferenced in the bucket.
|