|
@@ -176,14 +176,74 @@ function s3_presign_get(string $key, ?int $ttl = null): string
|
|
|
return s3_presign('GET', $key, $ttl);
|
|
return s3_presign('GET', $key, $ttl);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * One curl handle per PHP process, reused across requests to the same endpoint.
|
|
|
|
|
+ * curl_reset() clears the options but keeps the handle's live connection, DNS
|
|
|
|
|
+ * and TLS-session caches, so the second PUT of a request (the thumbnail) and
|
|
|
|
|
+ * any retry skip a full TCP + TLS handshake.
|
|
|
|
|
+ */
|
|
|
|
|
+function s3_curl(): CurlHandle
|
|
|
|
|
+{
|
|
|
|
|
+ static $ch = null;
|
|
|
|
|
+ if ($ch === null) {
|
|
|
|
|
+ $ch = curl_init();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ curl_reset($ch);
|
|
|
|
|
+ }
|
|
|
|
|
+ return $ch;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Whether an S3 attempt failed in a way that is worth repeating: a curl-level
|
|
|
|
|
+ * failure (status 0), throttling, or a server-side error. 4xx is a real
|
|
|
|
|
+ * rejection (bad key, bad signature) and must not be retried.
|
|
|
|
|
+ */
|
|
|
|
|
+function s3_is_transient(int $status): bool
|
|
|
|
|
+{
|
|
|
|
|
+ return $status === 0 || $status === 408 || $status === 429 || $status >= 500;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Stream a local file to S3 with a signed PUT (header auth). The payload is sent
|
|
* Stream a local file to S3 with a signed PUT (header auth). The payload is sent
|
|
|
* as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
|
|
* as UNSIGNED-PAYLOAD so the body is never hashed or buffered into memory — curl
|
|
|
* streams it straight from the file handle, letting the webhost proxy originals
|
|
* streams it straight from the file handle, letting the webhost proxy originals
|
|
|
* far larger than memory_limit. Content-Type is sent but not signed.
|
|
* far larger than memory_limit. Content-Type is sent but not signed.
|
|
|
- * Returns [httpStatus, responseBody].
|
|
|
|
|
|
|
+ *
|
|
|
|
|
+ * Transient failures are retried up to $attempts times with a short backoff; the
|
|
|
|
|
+ * file handle is rewound and the request re-signed for each try, so a dropped
|
|
|
|
|
+ * connection costs one repeat instead of a failed image.
|
|
|
|
|
+ * Returns [httpStatus, responseBody] of the last attempt.
|
|
|
|
|
+ */
|
|
|
|
|
+function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream', int $attempts = 3): array
|
|
|
|
|
+{
|
|
|
|
|
+ // Suppressed: a warning printed here would land in front of the JSON body
|
|
|
|
|
+ // the API endpoints emit, and the failure is reported through the return.
|
|
|
|
|
+ $fh = @fopen($filePath, 'rb');
|
|
|
|
|
+ if ($fh === false) {
|
|
|
|
|
+ return [0, 'Cannot open upload for reading'];
|
|
|
|
|
+ }
|
|
|
|
|
+ $size = (int)filesize($filePath);
|
|
|
|
|
+
|
|
|
|
|
+ $status = 0;
|
|
|
|
|
+ $body = '';
|
|
|
|
|
+ for ($try = 1; $try <= $attempts; $try++) {
|
|
|
|
|
+ rewind($fh);
|
|
|
|
|
+ [$status, $body] = s3_put_stream($key, $fh, $size, $contentType);
|
|
|
|
|
+ if (!s3_is_transient($status) || $try === $attempts) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ usleep(250000 * $try); // 0.25s, then 0.5s
|
|
|
|
|
+ }
|
|
|
|
|
+ fclose($fh);
|
|
|
|
|
+ return [$status, $body];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * One signed PUT attempt streaming from an open, positioned file handle.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param resource $fh
|
|
|
*/
|
|
*/
|
|
|
-function s3_put_file(string $key, string $filePath, string $contentType = 'application/octet-stream'): array
|
|
|
|
|
|
|
+function s3_put_stream(string $key, $fh, int $size, string $contentType): array
|
|
|
{
|
|
{
|
|
|
$host = s3_host();
|
|
$host = s3_host();
|
|
|
$amzDate = gmdate('Ymd\THis\Z');
|
|
$amzDate = gmdate('Ymd\THis\Z');
|
|
@@ -216,18 +276,21 @@ function s3_put_file(string $key, string $filePath, string $contentType = 'appli
|
|
|
. ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
|
|
. ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
|
|
|
. ', Signature=' . $signature;
|
|
. ', Signature=' . $signature;
|
|
|
|
|
|
|
|
- $fh = fopen($filePath, 'rb');
|
|
|
|
|
- if ($fh === false) {
|
|
|
|
|
- return [0, 'Cannot open upload for reading'];
|
|
|
|
|
- }
|
|
|
|
|
- $ch = curl_init(s3_base_url() . $canonicalUri);
|
|
|
|
|
|
|
+ $ch = s3_curl();
|
|
|
curl_setopt_array($ch, [
|
|
curl_setopt_array($ch, [
|
|
|
|
|
+ CURLOPT_URL => s3_base_url() . $canonicalUri,
|
|
|
CURLOPT_UPLOAD => true, // sets method to PUT and streams CURLOPT_INFILE
|
|
CURLOPT_UPLOAD => true, // sets method to PUT and streams CURLOPT_INFILE
|
|
|
CURLOPT_INFILE => $fh,
|
|
CURLOPT_INFILE => $fh,
|
|
|
- CURLOPT_INFILESIZE => filesize($filePath),
|
|
|
|
|
|
|
+ CURLOPT_INFILESIZE => $size,
|
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
|
CURLOPT_CONNECTTIMEOUT => 30,
|
|
CURLOPT_CONNECTTIMEOUT => 30,
|
|
|
CURLOPT_TIMEOUT => 0, // no cap: originals can be large
|
|
CURLOPT_TIMEOUT => 0, // no cap: originals can be large
|
|
|
|
|
+ // Abort a connection that has stalled below 1 KB/s for two minutes,
|
|
|
|
|
+ // instead of pinning a PHP worker on a dead socket until the web
|
|
|
|
|
+ // server kills it. A retry then gets a fresh connection.
|
|
|
|
|
+ CURLOPT_LOW_SPEED_LIMIT => 1024,
|
|
|
|
|
+ CURLOPT_LOW_SPEED_TIME => 120,
|
|
|
|
|
+ CURLOPT_TCP_NODELAY => true,
|
|
|
CURLOPT_HTTPHEADER => [
|
|
CURLOPT_HTTPHEADER => [
|
|
|
'Authorization: ' . $authorization,
|
|
'Authorization: ' . $authorization,
|
|
|
'x-amz-content-sha256: ' . $payloadHash,
|
|
'x-amz-content-sha256: ' . $payloadHash,
|
|
@@ -238,7 +301,6 @@ function s3_put_file(string $key, string $filePath, string $contentType = 'appli
|
|
|
]);
|
|
]);
|
|
|
$body = curl_exec($ch);
|
|
$body = curl_exec($ch);
|
|
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
- fclose($fh);
|
|
|
|
|
return [$status, (string)$body];
|
|
return [$status, (string)$body];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -279,8 +341,9 @@ function s3_request(string $method, string $key): array
|
|
|
. ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
|
|
. ', SignedHeaders=host;x-amz-content-sha256;x-amz-date'
|
|
|
. ', Signature=' . $signature;
|
|
. ', Signature=' . $signature;
|
|
|
|
|
|
|
|
- $ch = curl_init(s3_base_url() . $canonicalUri);
|
|
|
|
|
|
|
+ $ch = s3_curl();
|
|
|
curl_setopt_array($ch, [
|
|
curl_setopt_array($ch, [
|
|
|
|
|
+ CURLOPT_URL => s3_base_url() . $canonicalUri,
|
|
|
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
|
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_TIMEOUT => 30,
|
|
@@ -332,9 +395,11 @@ function upload_error_message(int $code): string
|
|
|
* browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
|
|
* browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
|
|
|
*
|
|
*
|
|
|
* Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
|
|
* Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
|
|
|
- * The gallery is re-loaded under a fresh read before appending to reduce lost
|
|
|
|
|
- * updates between concurrent uploads. Object keys are generated server-side
|
|
|
|
|
- * under the gallery's own prefix — never taken from the client.
|
|
|
|
|
|
|
+ * The browser uploads several images at once, so the gallery entry is appended
|
|
|
|
|
+ * 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.
|
|
|
*
|
|
*
|
|
|
* $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
|
|
* $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
|
|
|
* original must have a recognised image extension and decode via getimagesize(),
|
|
* original must have a recognised image extension and decode via getimagesize(),
|
|
@@ -389,15 +454,23 @@ function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Append under a fresh load to reduce lost updates between concurrent uploads.
|
|
|
|
|
- $gallery = gallery_load($slug);
|
|
|
|
|
- $gallery['images'][] = [
|
|
|
|
|
|
|
+ // Locked read-modify-write: concurrent uploads append without clobbering.
|
|
|
|
|
+ $count = gallery_append_image($slug, [
|
|
|
'key' => $key,
|
|
'key' => $key,
|
|
|
'thumb' => $thumbKey,
|
|
'thumb' => $thumbKey,
|
|
|
'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
|
|
'name' => substr((string)($original['name'] ?? basename($key)), 0, 200),
|
|
|
'size' => (int)($original['size'] ?? 0),
|
|
'size' => (int)($original['size'] ?? 0),
|
|
|
- ];
|
|
|
|
|
- gallery_save($gallery);
|
|
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // The gallery was deleted while this image was in flight: drop the objects
|
|
|
|
|
+ // we just wrote rather than leaving them unreferenced in the bucket.
|
|
|
|
|
+ if ($count === null) {
|
|
|
|
|
+ s3_delete($key);
|
|
|
|
|
+ if ($thumbKey !== null) {
|
|
|
|
|
+ s3_delete($thumbKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ return [404, ['error' => 'Gallery no longer exists']];
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => count($gallery['images'])]];
|
|
|
|
|
|
|
+ return [200, ['ok' => true, 'key' => $key, 'thumb' => $thumbKey, 'count' => $count]];
|
|
|
}
|
|
}
|