feat: add per-user S3 bucket for sandbox artifact uploads
Users can configure their own S3-compatible bucket credentials in User Settings > Artifact Storage. When configured, sandbox mode shows an "Upload to Bucket" button that pushes exported artifacts (filtered images, video captures) directly to the user's bucket via presigned POST. Supports DigitalOcean Spaces, AWS S3, MinIO, Backblaze B2, and any S3-compatible service. Files never touch the MPS server — two-step presign pattern matches existing product upload architecture.
This commit is contained in:
parent
d3c6a3cdd2
commit
f07c37b67a
9 changed files with 458 additions and 3 deletions
|
|
@ -78,6 +78,13 @@ class User(RBase, Base):
|
|||
# 0 is dark, 1 is light, could have more themes when h@ckz0rs unite.
|
||||
theme_id = Column(BigInteger, nullable=False, default=1)
|
||||
|
||||
# S3-compatible bucket for sandbox artifact uploads
|
||||
s3_endpoint = Column(Unicode(256), nullable=True)
|
||||
s3_region = Column(Unicode(64), nullable=True)
|
||||
s3_bucket = Column(Unicode(128), nullable=True)
|
||||
s3_access_key = Column(Unicode(128), nullable=True)
|
||||
s3_secret_key = Column(Unicode(128), nullable=True)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
shops = association_proxy("user_shops", "shop", creator=lambda s: UserShop(shop=s))
|
||||
|
||||
|
|
@ -181,6 +188,14 @@ class User(RBase, Base):
|
|||
def password_timestamp_delta(self):
|
||||
return now_timestamp() - self.password_timestamp
|
||||
|
||||
@property
|
||||
def has_s3_bucket(self):
|
||||
"""Return True if user has S3 bucket credentials configured."""
|
||||
return bool(
|
||||
self.s3_endpoint and self.s3_bucket
|
||||
and self.s3_access_key and self.s3_secret_key
|
||||
)
|
||||
|
||||
def set_active_shop(self, shop):
|
||||
self.active_shop_id = shop.id
|
||||
self.dbsession.add(self)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ def includeme(config):
|
|||
config.add_route("user_settings", "/u/settings")
|
||||
config.add_route("user_crypto_settings", "/u/settings/crypto")
|
||||
config.add_route("user_crypto_settings_update", "/u/settings/crypto/{coin_type}")
|
||||
config.add_route("user_storage_settings", "/u/settings/storage")
|
||||
config.add_route("user_sandbox_upload", "/u/sandbox/upload")
|
||||
config.add_route("user_purchases", "/u/purchases")
|
||||
config.add_route("user_addresses", "/u/addresses")
|
||||
config.add_route("user_address_save", "/u/addresses/save")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
"""add S3 bucket credentials to user
|
||||
|
||||
Revision ID: f898ba460612
|
||||
Revises: a2c13d3117f2
|
||||
Create Date: 2026-02-27 12:35:45.090589
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f898ba460612'
|
||||
down_revision = 'a2c13d3117f2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
columns = [
|
||||
("s3_endpoint", sa.Unicode(256)),
|
||||
("s3_region", sa.Unicode(64)),
|
||||
("s3_bucket", sa.Unicode(128)),
|
||||
("s3_access_key", sa.Unicode(128)),
|
||||
("s3_secret_key", sa.Unicode(128)),
|
||||
]
|
||||
for col_name, col_type in columns:
|
||||
if not _column_exists("mps_user", col_name):
|
||||
op.add_column("mps_user", sa.Column(col_name, col_type, nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
for col_name in ("s3_endpoint", "s3_region", "s3_bucket", "s3_access_key", "s3_secret_key"):
|
||||
op.drop_column("mps_user", col_name)
|
||||
|
|
@ -75,6 +75,7 @@
|
|||
var mediaSelector = 'img, video';
|
||||
|
||||
// ---- DOM refs ----
|
||||
var toolbar = document.getElementById('sandbox-toolbar');
|
||||
var toggleBtn = document.getElementById('sandbox-toggle');
|
||||
var panel = document.getElementById('sandbox-panel');
|
||||
var closeBtn = document.getElementById('sandbox-close');
|
||||
|
|
@ -84,6 +85,12 @@
|
|||
|
||||
if (!toggleBtn || !panel) return;
|
||||
|
||||
// Does the user have an S3 bucket configured?
|
||||
var hasBucket = toolbar && toolbar.getAttribute('data-has-bucket') === '1';
|
||||
var lastExportBlob = null;
|
||||
var lastExportFilename = null;
|
||||
var lastExportContentType = null;
|
||||
|
||||
// ---- Face detection state ----
|
||||
var faceMeshLoaded = false;
|
||||
var faceMeshModule = null;
|
||||
|
|
@ -353,6 +360,22 @@
|
|||
actionsContainer.appendChild(pageBtn);
|
||||
actionsContainer.appendChild(exportImgBtn);
|
||||
actionsContainer.appendChild(exportVidBtn);
|
||||
|
||||
// Upload to bucket button — only when user has S3 credentials
|
||||
if (hasBucket) {
|
||||
var uploadBtn = document.createElement('button');
|
||||
uploadBtn.className = 'sandbox-action-btn sandbox-export-btn';
|
||||
uploadBtn.textContent = 'Upload to Bucket';
|
||||
uploadBtn.title = 'Upload the last exported artifact to your S3 bucket';
|
||||
uploadBtn.addEventListener('click', function() {
|
||||
if (!lastExportBlob) {
|
||||
showStatus('Export an image or video first, then upload.');
|
||||
return;
|
||||
}
|
||||
uploadToBucket(lastExportBlob, lastExportFilename, lastExportContentType);
|
||||
});
|
||||
actionsContainer.appendChild(uploadBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -387,8 +410,15 @@
|
|||
showStatus('Export failed — could not generate image.');
|
||||
return;
|
||||
}
|
||||
lastExportBlob = blob;
|
||||
lastExportFilename = 'sandbox-export.png';
|
||||
lastExportContentType = 'image/png';
|
||||
downloadBlob(blob, 'sandbox-export.png');
|
||||
showStatus('Image exported.');
|
||||
if (hasBucket) {
|
||||
showStatus('Image exported. Click "Upload to Bucket" to save to your storage.');
|
||||
} else {
|
||||
showStatus('Image exported.');
|
||||
}
|
||||
}, 'image/png');
|
||||
};
|
||||
exportImg.onerror = function() {
|
||||
|
|
@ -459,9 +489,16 @@
|
|||
|
||||
videoRecorder.onstop = function() {
|
||||
var blob = new Blob(videoChunks, { type: mimeType });
|
||||
lastExportBlob = blob;
|
||||
lastExportFilename = 'sandbox-export.webm';
|
||||
lastExportContentType = mimeType;
|
||||
downloadBlob(blob, 'sandbox-export.webm');
|
||||
videoExportActive = false;
|
||||
showStatus('Video exported.');
|
||||
if (hasBucket) {
|
||||
showStatus('Video exported. Click "Upload to Bucket" to save to your storage.');
|
||||
} else {
|
||||
showStatus('Video exported.');
|
||||
}
|
||||
};
|
||||
|
||||
videoRecorder.start(100); // Collect data every 100ms
|
||||
|
|
@ -488,6 +525,52 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Upload to user's S3 bucket
|
||||
// ========================================
|
||||
function uploadToBucket(blob, filename, contentType) {
|
||||
showStatus('Requesting upload URL...');
|
||||
|
||||
fetch('/u/sandbox/upload', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'filename=' + encodeURIComponent(filename)
|
||||
+ '&content_type=' + encodeURIComponent(contentType),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showStatus(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('Uploading to bucket...');
|
||||
|
||||
var formData = new FormData();
|
||||
var fields = data.presigned.fields;
|
||||
Object.keys(fields).forEach(function(k) {
|
||||
formData.append(k, fields[k]);
|
||||
});
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
return fetch(data.presigned.url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response) return; // error case already handled
|
||||
if (response.ok || response.status === 204) {
|
||||
showStatus('Uploaded to your bucket.');
|
||||
} else {
|
||||
showStatus('Upload failed (HTTP ' + response.status + '). Check bucket permissions.');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
showStatus('Upload failed: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Face detection (MediaPipe) — on demand
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@
|
|||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="sandbox-toolbar" class="sandbox-toolbar js-only">
|
||||
<div id="sandbox-toolbar" class="sandbox-toolbar js-only"{% if request.user and request.user.authenticated and request.user.has_s3_bucket %} data-has-bucket="1"{% endif %}>
|
||||
<button id="sandbox-toggle" class="sandbox-toggle-btn" title="Toggle sandbox filters">🎨 Filters</button>
|
||||
<div id="sandbox-panel" class="sandbox-panel" style="display:none">
|
||||
<div class="sandbox-panel-header">
|
||||
|
|
|
|||
|
|
@ -102,6 +102,78 @@
|
|||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<br />
|
||||
|
||||
<h3>Artifact Storage</h3>
|
||||
<small class="note-text">Connect an S3-compatible bucket to upload sandbox artifacts directly to your own storage. Works with DigitalOcean Spaces, AWS S3, MinIO, Backblaze B2, and any S3-compatible service.</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<form method="post" action="/u/settings/storage" onsubmit="submit.disabled = true; return true;">
|
||||
|
||||
<label for="s3_endpoint_input">S3 Endpoint URL</label>
|
||||
<input
|
||||
name = "s3_endpoint"
|
||||
type = "url"
|
||||
id = "s3_endpoint_input"
|
||||
value = "{{ s3_endpoint }}"
|
||||
placeholder = "https://nyc3.digitaloceanspaces.com" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="s3_region_input">Region (optional)</label>
|
||||
<input
|
||||
name = "s3_region"
|
||||
type = "text"
|
||||
id = "s3_region_input"
|
||||
value = "{{ s3_region }}"
|
||||
placeholder = "nyc3" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="s3_bucket_input">Bucket Name</label>
|
||||
<input
|
||||
name = "s3_bucket"
|
||||
type = "text"
|
||||
id = "s3_bucket_input"
|
||||
value = "{{ s3_bucket }}"
|
||||
placeholder = "my-artifacts" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="s3_access_key_input">Access Key</label>
|
||||
<input
|
||||
name = "s3_access_key"
|
||||
type = "text"
|
||||
id = "s3_access_key_input"
|
||||
value = "{{ s3_access_key }}"
|
||||
placeholder = "Access Key ID" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="s3_secret_key_input">Secret Key</label>
|
||||
<input
|
||||
name = "s3_secret_key"
|
||||
type = "password"
|
||||
id = "s3_secret_key_input"
|
||||
value = "{{ s3_secret_key }}"
|
||||
placeholder = "Secret Access Key" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Storage Settings" />
|
||||
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="settings-right">
|
||||
|
|
|
|||
|
|
@ -3853,6 +3853,131 @@ class TestAnalytics(AuthenticatedFunctionalTests):
|
|||
self.assertIn('sandbox.js', res.text)
|
||||
self.assertIn('sandbox-toolbar', res.text)
|
||||
|
||||
def test_user_s3_bucket_default_empty(self):
|
||||
"""Test that new users have no S3 bucket credentials."""
|
||||
self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
self.dbsession.expire(self.user1)
|
||||
self.assertIsNone(self.user1.s3_endpoint)
|
||||
self.assertIsNone(self.user1.s3_bucket)
|
||||
self.assertFalse(self.user1.has_s3_bucket)
|
||||
|
||||
def test_user_s3_bucket_save(self):
|
||||
"""Test saving S3 bucket credentials via settings form."""
|
||||
self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
|
||||
res = self.testapp.post(
|
||||
"/u/settings/storage",
|
||||
{
|
||||
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
|
||||
"s3_region": "nyc3",
|
||||
"s3_bucket": "my-artifacts",
|
||||
"s3_access_key": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn("Artifact storage settings saved", res.text)
|
||||
|
||||
self.dbsession.expire(self.user1)
|
||||
self.assertEqual(self.user1.s3_endpoint, "https://nyc3.digitaloceanspaces.com")
|
||||
self.assertEqual(self.user1.s3_region, "nyc3")
|
||||
self.assertEqual(self.user1.s3_bucket, "my-artifacts")
|
||||
self.assertTrue(self.user1.has_s3_bucket)
|
||||
|
||||
def test_user_s3_bucket_clear(self):
|
||||
"""Test clearing S3 bucket credentials."""
|
||||
self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
|
||||
# First save some credentials
|
||||
self.testapp.post(
|
||||
"/u/settings/storage",
|
||||
{
|
||||
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
|
||||
"s3_region": "nyc3",
|
||||
"s3_bucket": "my-artifacts",
|
||||
"s3_access_key": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
|
||||
# Now clear them by posting empty fields
|
||||
res = self.testapp.post(
|
||||
"/u/settings/storage",
|
||||
{
|
||||
"s3_endpoint": "",
|
||||
"s3_region": "",
|
||||
"s3_bucket": "",
|
||||
"s3_access_key": "",
|
||||
"s3_secret_key": "",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn("Artifact storage credentials cleared", res.text)
|
||||
|
||||
self.dbsession.expire(self.user1)
|
||||
self.assertFalse(self.user1.has_s3_bucket)
|
||||
|
||||
def test_sandbox_upload_no_bucket(self):
|
||||
"""Test that sandbox upload returns error when no bucket configured."""
|
||||
self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
|
||||
res = self.testapp.post(
|
||||
"/u/sandbox/upload",
|
||||
{"filename": "test.png", "content_type": "image/png"},
|
||||
status=400,
|
||||
)
|
||||
self.assertIn("error", res.json)
|
||||
self.assertIn("No S3 bucket configured", res.json["error"])
|
||||
|
||||
def test_sandbox_toolbar_bucket_attribute(self):
|
||||
"""Test that toolbar has data-has-bucket when user has S3 credentials."""
|
||||
shop = self._create_shop_helper(
|
||||
user_creds=self.user1_creds, shop_params=self.shop1_params
|
||||
)
|
||||
|
||||
# Enable sandbox mode
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "ribbon-settings",
|
||||
"sandbox_mode": "1",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
|
||||
# Without S3 credentials, no data-has-bucket
|
||||
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
|
||||
self.assertNotIn('data-has-bucket', res.text)
|
||||
|
||||
# Save S3 credentials
|
||||
self.testapp.post(
|
||||
"/u/settings/storage",
|
||||
{
|
||||
"s3_endpoint": "https://nyc3.digitaloceanspaces.com",
|
||||
"s3_region": "nyc3",
|
||||
"s3_bucket": "my-artifacts",
|
||||
"s3_access_key": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
|
||||
# Now toolbar should have data-has-bucket
|
||||
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
|
||||
self.assertIn('data-has-bucket="1"', res.text)
|
||||
|
||||
@patch("boto3.session.Session")
|
||||
@patch("smtplib.SMTP")
|
||||
def test_edit_page_shows_media_preview_for_product_file(self, mock_smtp, mock_session_cls):
|
||||
|
|
|
|||
|
|
@ -86,9 +86,60 @@ def user_settings(request):
|
|||
"name": name,
|
||||
"full_name": full_name,
|
||||
"theme_id": user.theme_id,
|
||||
"s3_endpoint": user.s3_endpoint or "",
|
||||
"s3_region": user.s3_region or "",
|
||||
"s3_bucket": user.s3_bucket or "",
|
||||
"s3_access_key": user.s3_access_key or "",
|
||||
"s3_secret_key": user.s3_secret_key or "",
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="user_storage_settings", request_method="POST")
|
||||
@user_required()
|
||||
def user_storage_settings(request):
|
||||
"""Save or clear S3-compatible bucket credentials for artifact uploads."""
|
||||
user = request.user
|
||||
|
||||
s3_endpoint = request.params.get("s3_endpoint", "").strip()
|
||||
s3_region = request.params.get("s3_region", "").strip()
|
||||
s3_bucket = request.params.get("s3_bucket", "").strip()
|
||||
s3_access_key = request.params.get("s3_access_key", "").strip()
|
||||
s3_secret_key = request.params.get("s3_secret_key", "").strip()
|
||||
|
||||
# If all fields are empty, clear credentials
|
||||
if not any([s3_endpoint, s3_bucket, s3_access_key, s3_secret_key]):
|
||||
user.s3_endpoint = None
|
||||
user.s3_region = None
|
||||
user.s3_bucket = None
|
||||
user.s3_access_key = None
|
||||
user.s3_secret_key = None
|
||||
request.session.flash(("Artifact storage credentials cleared.", "success"))
|
||||
return HTTPFound("/u/settings")
|
||||
|
||||
# Validate endpoint looks like a URL
|
||||
if s3_endpoint and not s3_endpoint.startswith("http"):
|
||||
request.session.flash(
|
||||
("S3 endpoint must start with http:// or https://", "error")
|
||||
)
|
||||
return HTTPFound("/u/settings")
|
||||
|
||||
# Require at minimum endpoint, bucket, and both keys
|
||||
if not (s3_endpoint and s3_bucket and s3_access_key and s3_secret_key):
|
||||
request.session.flash(
|
||||
("Endpoint, bucket name, access key, and secret key are all required.", "error")
|
||||
)
|
||||
return HTTPFound("/u/settings")
|
||||
|
||||
user.s3_endpoint = s3_endpoint
|
||||
user.s3_region = s3_region or None
|
||||
user.s3_bucket = s3_bucket
|
||||
user.s3_access_key = s3_access_key
|
||||
user.s3_secret_key = s3_secret_key
|
||||
|
||||
request.session.flash(("Artifact storage settings saved.", "success"))
|
||||
return HTTPFound("/u/settings")
|
||||
|
||||
|
||||
@view_config(route_name="user_addresses", renderer="user_addresses.j2")
|
||||
@user_required()
|
||||
def user_addresses(request):
|
||||
|
|
|
|||
65
make_post_sell/views/user_sandbox.py
Normal file
65
make_post_sell/views/user_sandbox.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import logging
|
||||
import time
|
||||
|
||||
from pyramid.view import view_config
|
||||
|
||||
from . import user_required
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@view_config(route_name="user_sandbox_upload", renderer="json", request_method="POST")
|
||||
@user_required()
|
||||
def sandbox_presign(request):
|
||||
"""Generate a presigned POST for uploading a sandbox artifact to the user's S3 bucket.
|
||||
|
||||
The browser sends filename and content_type. This endpoint creates a boto3
|
||||
client with the user's stored credentials, generates a presigned POST, and
|
||||
returns the URL + fields. The browser then uploads the blob directly to the
|
||||
user's bucket — the file never touches the MPS server.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
if not user.has_s3_bucket:
|
||||
request.response.status_code = 400
|
||||
return {"error": "No S3 bucket configured. Go to User Settings > Artifact Storage."}
|
||||
|
||||
filename = request.params.get("filename", "sandbox-export.png").strip()
|
||||
content_type = request.params.get("content_type", "image/png").strip()
|
||||
|
||||
# Sanitize filename — keep only safe characters
|
||||
safe_filename = "".join(
|
||||
c for c in filename if c.isalnum() or c in ".-_"
|
||||
) or "sandbox-export"
|
||||
|
||||
# Build S3 key: sandbox/{user_id}/{timestamp}-{filename}
|
||||
key = "sandbox/{}/{}-{}".format(user.uuid_str, int(time.time()), safe_filename)
|
||||
|
||||
try:
|
||||
import boto3
|
||||
|
||||
session = boto3.session.Session()
|
||||
client = session.client(
|
||||
"s3",
|
||||
region_name=user.s3_region or "us-east-1",
|
||||
endpoint_url=user.s3_endpoint,
|
||||
aws_access_key_id=user.s3_access_key,
|
||||
aws_secret_access_key=user.s3_secret_key,
|
||||
)
|
||||
|
||||
presigned = client.generate_presigned_post(
|
||||
Bucket=user.s3_bucket,
|
||||
Key=key,
|
||||
ExpiresIn=900,
|
||||
Conditions=[
|
||||
["content-length-range", 1, 50 * 1024 * 1024], # 50MB max
|
||||
],
|
||||
Fields={"Content-Type": content_type},
|
||||
)
|
||||
|
||||
return {"presigned": presigned, "key": key}
|
||||
|
||||
except Exception as e:
|
||||
log.warning("sandbox_presign failed for user %s: %s", user.uuid_str, e)
|
||||
request.response.status_code = 500
|
||||
return {"error": "Failed to generate upload URL. Check your S3 credentials."}
|
||||
Loading…
Add table
Add a link
Reference in a new issue