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>
8 files changed,
+14 insertions,
-11 deletions
| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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', {
|