Skip to main content

max / makenotwork

Fix S3 upload failures: send Cache-Control header to match presigned URL signature Browser uploads failed with "Network error" because presigned URLs included Cache-Control in the signature but the XHR wasn't sending that header, causing S3 to reject with 403. Now the presign response includes the cache_control value and S3Uploader sends it. Also adds application/x-diskcopy MIME for DMG uploads and removes tags hint from item wizard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 00:44 UTC
Commit: 2127f74b467e2cddc0b4288b74ba3bb5a0f53cfa
Parent: 092d3c2
8 files changed, +14 insertions, -11 deletions
@@ -63,6 +63,9 @@ pub struct PresignUploadResponse {
63 63 pub upload_url: String,
64 64 pub s3_key: String,
65 65 pub expires_in: u64,
66 + /// Cache-Control header the client must send with the S3 PUT (part of the presigned signature).
67 + #[serde(skip_serializing_if = "Option::is_none")]
68 + pub cache_control: Option<String>,
66 69 }
67 70
68 71 /// JSON input for confirming a completed S3 upload.
@@ -239,6 +242,7 @@ async fn presign_upload(
239 242 upload_url,
240 243 s3_key,
241 244 expires_in,
245 + cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
242 246 }))
243 247 }
244 248
@@ -465,6 +469,7 @@ async fn version_presign_upload(
465 469 upload_url,
466 470 s3_key,
467 471 expires_in,
472 + cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
468 473 }))
469 474 }
470 475
@@ -662,6 +667,7 @@ async fn project_image_presign(
662 667 upload_url,
663 668 s3_key,
664 669 expires_in,
670 + cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
665 671 }))
666 672 }
667 673
@@ -199,6 +199,7 @@ impl S3Client {
199 199 .allowed_methods("GET")
200 200 .allowed_methods("HEAD")
201 201 .allowed_headers("Content-Type")
202 + .allowed_headers("Cache-Control")
202 203 .allowed_headers("Content-Disposition")
203 204 .expose_headers("ETag")
204 205 .max_age_seconds(3600)
@@ -21,7 +21,7 @@ function S3Uploader(opts) {
21 21 }
22 22
23 23 /** Start an upload. Returns a Promise resolving to the s3Key. */
24 - S3Uploader.prototype.upload = function(url, file, s3Key, fallbackContentType) {
24 + S3Uploader.prototype.upload = function(url, file, s3Key, fallbackContentType, cacheControl) {
25 25 var self = this;
26 26
27 27 if (self.filenameEl) self.filenameEl.textContent = file.name;
@@ -61,6 +61,7 @@ S3Uploader.prototype.upload = function(url, file, s3Key, fallbackContentType) {
61 61
62 62 xhr.open('PUT', url);
63 63 xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream');
64 + if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl);
64 65 xhr.send(file);
65 66 });
66 67 };
@@ -108,7 +108,7 @@
108 108 return res.json();
109 109 })
110 110 .then(function(data) {
111 - return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg');
111 + return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg', data.cache_control);
112 112 })
113 113 .then(function(s3Key) {
114 114 return fetch('/api/upload/confirm', {
@@ -221,7 +221,7 @@
221 221 return res.json();
222 222 })
223 223 .then(function(data) {
224 - return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream');
224 + return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
225 225 })
226 226 .then(function(s3Key) {
227 227 return fetch('/api/versions/' + versionId + '/upload/confirm', {
@@ -239,7 +239,7 @@
239 239 var presignData = await presignRes.json();
240 240
241 241 var uploader = new S3Uploader({});
242 - await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType);
242 + await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
243 243
244 244 var confirmRes = await fetch('/api/upload/confirm', {
245 245 method: 'POST',
@@ -270,7 +270,7 @@
270 270 var presignData = await presignRes.json();
271 271
272 272 var uploader = new S3Uploader({});
273 - await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType);
273 + await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control);
274 274
275 275 var confirmRes = await fetch('/api/versions/' + versionId + '/upload/confirm', {
276 276 method: 'POST',
@@ -20,11 +20,6 @@
20 20 placeholder="Describe this item...">{{ description }}</textarea>
21 21 </div>
22 22
23 - <div class="form-group">
24 - <label>Tags</label>
25 - <div class="hint">You can add tags from the item dashboard after creation.</div>
26 - </div>
27 -
28 23 <div class="wizard-actions">
29 24 <button type="button" class="secondary"
30 25 hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/type"
@@ -121,7 +121,7 @@
121 121 return res.json();
122 122 })
123 123 .then(function(data) {
124 - return uploader.upload(data.upload_url, file, data.s3_key, file.type || 'image/jpeg');
124 + return uploader.upload(data.upload_url, file, data.s3_key, file.type || 'image/jpeg', data.cache_control);
125 125 })
126 126 .then(function(s3Key) {
127 127 return fetch('/api/projects/image/confirm', {