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:
russell@unturf.com 2026-02-27 12:57:10 -05:00
parent d3c6a3cdd2
commit f07c37b67a
9 changed files with 458 additions and 3 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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
// ========================================

View file

@ -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">&#x1F3A8; Filters</button>
<div id="sandbox-panel" class="sandbox-panel" style="display:none">
<div class="sandbox-panel-header">

View file

@ -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">

View file

@ -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):

View file

@ -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):

View 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."}