feat: implement MPS-14 (environment), MPS-15 (trial), MPS-16 (BYOB)

MPS-14: Add environment column (production/staging/development) to shops.
Non-production shops excluded from feeds, search, and discovery.
Environment banner in base template. Settings UI and creation selector.

MPS-15: Add 21-day free trial with trial_started_timestamp on shop
creation. Properties: is_trial_active, is_trial_expired, trial_days_remaining,
is_active. Grandfathered pre-trial shops (NULL timestamp = paid).
Trial banner in base template.

MPS-16: BYOB (Bring Your Own Bucket) - per-shop S3 storage with
primary_s3_* columns. Shop-aware request methods (shop_uploads_client,
shop_bucket_name, shop_cdn_endpoint) that fall back to MPS default.
All templates and views updated from global to shop-aware S3 references.
Bucket settings UI in shop settings.

Migration: 9884324a48e3 (idempotent, 11 new columns on mps_shop).
Tests: 14 unit tests + 8 functional tests (712 total, all pass).
This commit is contained in:
russell@unturf.com 2026-03-07 18:14:31 -05:00
parent 0fc8b62aa1
commit 2cb1e79400
25 changed files with 918 additions and 85 deletions

View file

@ -200,7 +200,7 @@ def send_purchase_email(request, to_email, products, total_cost):
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
request.app["bucket.secure_uploads.get_endpoint"],
request.shop_cdn_endpoint,
p.s3_path,
p.updated_timestamp,
)
@ -239,7 +239,7 @@ def send_sale_email(request, shop, products, total_cost):
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
request.app["bucket.secure_uploads.get_endpoint"],
request.shop_cdn_endpoint,
p.s3_path,
p.updated_timestamp,
)

View file

@ -166,6 +166,23 @@ class Shop(RBase, Base):
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
# Shop environment: 0=production, 1=staging, 2=development
environment = Column(BigInteger, nullable=False, default=0)
# Trial and billing
trial_started_timestamp = Column(BigInteger, nullable=True)
trial_ended = Column(Boolean, default=False)
plan_active = Column(Boolean, default=False)
# Primary S3 bucket (Bring Your Own Bucket)
primary_s3_endpoint = Column(Unicode(256), nullable=True)
primary_s3_region = Column(Unicode(64), nullable=True)
primary_s3_bucket = Column(Unicode(128), nullable=True)
primary_s3_access_key = Column(Unicode(128), nullable=True)
primary_s3_secret_key = Column(Unicode(128), nullable=True)
primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
primary_s3_enabled = Column(Boolean, default=False)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)
@ -248,6 +265,94 @@ class Shop(RBase, Base):
us.role_id = role_id
return us
# --- Environment properties (MPS-14) ---
@property
def is_production(self):
return self.environment == 0
@property
def is_staging(self):
return self.environment == 1
@property
def is_development(self):
return self.environment == 2
@property
def is_non_production(self):
return self.environment != 0
@property
def environment_label(self):
return {0: "Production", 1: "Staging", 2: "Development"}.get(
self.environment, "Production"
)
# --- Trial properties (MPS-15) ---
TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days
@property
def trial_expiry_timestamp(self):
if self.trial_started_timestamp is None:
return None
return self.trial_started_timestamp + self.TRIAL_DURATION_MS
@property
def is_trial_active(self):
if self.plan_active:
return False
if self.trial_started_timestamp is None:
return False
import time
now = int(time.time() * 1000)
return now < self.trial_expiry_timestamp
@property
def is_trial_expired(self):
if self.plan_active or self.trial_started_timestamp is None:
return False
import time
now = int(time.time() * 1000)
return now >= self.trial_expiry_timestamp
@property
def trial_days_remaining(self):
if not self.is_trial_active:
return 0
import time
remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
return max(0, remaining_ms // (24 * 60 * 60 * 1000))
@property
def is_active(self):
"""Shop can operate: either paid plan, active trial, or pre-trial (existing)."""
if self.plan_active:
return True
if self.trial_started_timestamp is None:
return True # grandfathered pre-trial shop
return self.is_trial_active
# --- BYOB properties (MPS-16) ---
@property
def has_primary_s3(self):
return bool(
self.primary_s3_enabled
and self.primary_s3_endpoint
and self.primary_s3_bucket
and self.primary_s3_access_key
and self.primary_s3_secret_key
and self.primary_s3_cdn_endpoint
)
@property
def media_cdn_endpoint(self):
if self.has_primary_s3:
return self.primary_s3_cdn_endpoint
return None
@property
def has_s3_mirror(self):
"""Return True if shop has S3 mirror bucket configured and enabled."""

View file

@ -155,6 +155,35 @@ def includeme(config):
aws_secret_access_key=request.app["bucket.secure_uploads.secret_key"],
)
def add_shop_uploads_client(request):
"""Return S3 client for the current shop (BYOB or MPS default)."""
shop = request.shop
if shop and shop.has_primary_s3:
import boto3
session = boto3.session.Session()
return session.client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return request.secure_uploads_client
def add_shop_bucket_name(request):
"""Return the bucket name for the current shop."""
shop = request.shop
if shop and shop.has_primary_s3:
return shop.primary_s3_bucket
return request.app["bucket.secure_uploads"]
def add_shop_cdn_endpoint(request):
"""Return the CDN endpoint for the current shop's media."""
shop = request.shop
if shop and shop.has_primary_s3:
return shop.primary_s3_cdn_endpoint
return request.app["bucket.secure_uploads.get_endpoint"]
def add_is_shop_domain(request):
"""
Returns True or False.
@ -373,6 +402,11 @@ def includeme(config):
add_secure_uploads_client, "secure_uploads_client", reify=True
)
# BYOB: shop-aware S3 client and bucket
config.add_request_method(add_shop_uploads_client, "shop_uploads_client", reify=True)
config.add_request_method(add_shop_bucket_name, "shop_bucket_name", reify=True)
config.add_request_method(add_shop_cdn_endpoint, "shop_cdn_endpoint", reify=True)
# Payment method checks
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
config.add_request_method(

View file

@ -0,0 +1,109 @@
"""add environment trial and primary s3 columns to shop
Revision ID: 9884324a48e3
Revises: f8201a9ba045
Create Date: 2026-03-07 17:49:35.847633
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9884324a48e3'
down_revision = 'f8201a9ba045'
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():
# MPS-14: Shop environment
if not _column_exists("mps_shop", "environment"):
op.add_column(
"mps_shop",
sa.Column("environment", sa.BigInteger(), nullable=False, server_default="0"),
)
# MPS-15: Trial and billing
if not _column_exists("mps_shop", "trial_started_timestamp"):
op.add_column(
"mps_shop",
sa.Column("trial_started_timestamp", sa.BigInteger(), nullable=True),
)
if not _column_exists("mps_shop", "trial_ended"):
op.add_column(
"mps_shop",
sa.Column("trial_ended", sa.Boolean(), nullable=False, server_default="0"),
)
if not _column_exists("mps_shop", "plan_active"):
op.add_column(
"mps_shop",
sa.Column("plan_active", sa.Boolean(), nullable=False, server_default="0"),
)
# MPS-16: Primary S3 bucket (BYOB)
if not _column_exists("mps_shop", "primary_s3_endpoint"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_endpoint", sa.Unicode(256), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_region"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_region", sa.Unicode(64), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_bucket"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_bucket", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_access_key"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_access_key", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_secret_key"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_secret_key", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_cdn_endpoint"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_cdn_endpoint", sa.Unicode(256), nullable=True),
)
if not _column_exists("mps_shop", "primary_s3_enabled"):
op.add_column(
"mps_shop",
sa.Column("primary_s3_enabled", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("mps_shop", "primary_s3_enabled")
op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
op.drop_column("mps_shop", "primary_s3_secret_key")
op.drop_column("mps_shop", "primary_s3_access_key")
op.drop_column("mps_shop", "primary_s3_bucket")
op.drop_column("mps_shop", "primary_s3_region")
op.drop_column("mps_shop", "primary_s3_endpoint")
op.drop_column("mps_shop", "plan_active")
op.drop_column("mps_shop", "trial_ended")
op.drop_column("mps_shop", "trial_started_timestamp")
op.drop_column("mps_shop", "environment")

View file

@ -497,6 +497,40 @@ section.main {
overflow-x: hidden;
}
.environment-banner {
display: grid;
align-items: center;
text-align: center;
font-weight: bold;
font-size: var(--font-size-sm);
padding: var(--space-2) var(--space-4);
color: var(--color-white);
background-color: var(--color-warning, #e6a700);
}
.environment-banner-development {
background-color: var(--color-info, #3b82f6);
}
.trial-banner {
display: grid;
align-items: center;
text-align: center;
font-size: var(--font-size-sm);
padding: var(--space-2) var(--space-4);
background-color: var(--color-info, #3b82f6);
color: var(--color-white);
}
.trial-banner a {
color: var(--color-white);
text-decoration: underline;
}
.trial-banner-expired {
background-color: var(--color-error, #dc2626);
}
/*
section.grid-nav {
grid-column: 1/-1;

View file

@ -65,7 +65,7 @@
<meta charset="utf-8">
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
<link rel="icon" href="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
<link rel="icon" href="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
{% endif %}
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
@ -87,6 +87,20 @@
<style>.js-only {display: none;}</style>
</noscript>
{% include 'snippets/ribbon.j2' %}
{% if request.shop and request.shop.is_non_production %}
<div class="environment-banner environment-banner-{{ request.shop.environment_label }}">
{{ request.shop.environment_label|upper }} ENVIRONMENT — This shop is not visible to the public.
</div>
{% endif %}
{% if request.shop and request.shop.is_trial_active %}
<div class="trial-banner">
Trial: {{ request.shop.trial_days_remaining }} day{{ 's' if request.shop.trial_days_remaining != 1 else '' }} remaining — <a href="/actions/view">Choose a plan</a>
</div>
{% elif request.shop and request.shop.is_trial_expired %}
<div class="trial-banner trial-banner-expired">
Trial expired — <a href="/actions/view">Choose a plan</a> to continue editing
</div>
{% endif %}
<section class="main">
<section class="nav-grid">
@ -102,7 +116,7 @@
{% endif %}
{% elif request.shop and request.shop.logo_banner %}
<a href="/"><img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner?ts={{ request.shop.updated_timestamp }}" /></a>
<a href="/"><img class="logo" src="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner?ts={{ request.shop.updated_timestamp }}" /></a>
{% else%}
Upload a logo in shop settings.
{% endif %}

View file

@ -121,7 +121,7 @@
<div class="cart-item">
{% if "thumbnail1" in product.extensions %}
<a href="/p/{{ product.uuid_str }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
</a>
{% endif %}
</div>

View file

@ -10,11 +10,11 @@
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
<meta property="og:site_name" content="{{ request.shop.name }}" />
{%- if "thumbnail1" in product.extensions %}
<meta property="og:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta property="og:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ product.title }}" />
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
<meta name="twitter:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
{%- endif %}
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
@ -39,7 +39,7 @@
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
<div class="watch-video-container">
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
@ -60,11 +60,11 @@
</noscript>
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
{% endif %}
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
@ -84,9 +84,9 @@
{% elif "thumbnail1" in product.extensions %}
{% if product.extensions.get("product") in video_extensions %}
{# Video: play button overlay, click to play inline #}
{% set video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
{% set video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ video_url }}')">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</div>
<noscript>
@ -95,8 +95,8 @@
<div class="video-click-to-play">click ▶ to play</div>
{% else %}
{# Non-video: click to open in new window #}
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<a target="_blank" href="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
</a>
{% endif %}
{% endif %}

View file

@ -87,7 +87,7 @@
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>

View file

@ -10,11 +10,11 @@
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
<meta property="og:site_name" content="{{ request.shop.name }}" />
{%- if "thumbnail1" in product.extensions %}
<meta property="og:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta property="og:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ product.title }}" />
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
<meta name="twitter:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<meta name="twitter:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
{%- endif %}
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
@ -40,7 +40,7 @@
{% if request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
{% else %}
{% set watch_video_url = signed_get_object_url %}
{% endif %}
@ -65,14 +65,14 @@
{% elif request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
{% else %}
{% set watch_audio_url = signed_get_object_url %}
{% endif %}
<div id="watch-media-container">
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
{% endif %}
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
@ -92,9 +92,9 @@
{% elif "thumbnail1" in product.extensions %}
{% if signed_get_object_url and product.extensions.get("product") in video_extensions %}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% set preview_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ preview_url }}')">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</div>
<noscript>
@ -103,13 +103,13 @@
<div class="video-click-to-play">click ▶ to play</div>
{% else %}
<a href="{{ signed_get_object_url }}" target="_blank" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% endif %}
{% else %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
{% endif %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") not in video_extensions and product.extensions.get("product") not in audio_extensions %}
@ -128,7 +128,7 @@
{% for file_key in product.file_thumbnail_keys %}
{% if file_key in product.s3_key_thumbnails %}
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] + "/" + product.s3_key_thumbnails[file_key] %}
{% set get_endpoint = request.shop_cdn_endpoint + "/" + product.s3_key_thumbnails[file_key] %}
<a href="{{ get_endpoint }}" target="_blank"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" class="product-thumbnail" /></a>
{% endif %}
{% endfor %}
@ -232,7 +232,7 @@
{% endif %}
{% if "preview" in product.extensions %}
<a href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/preview" target="_blank" class="product-preview-button mps-button">▶ Play Preview</a>
<a href="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/preview" target="_blank" class="product-preview-button mps-button">▶ Play Preview</a>
<br/>
{% endif %}

View file

@ -133,7 +133,7 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
<p>File Size: {{ product.human_file_bytes("preview") }}</p>
{% set preview_ext = product.extensions.get("preview", "") %}
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview?ts=" ~ product.updated_timestamp %}
{% set preview_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview?ts=" ~ product.updated_timestamp %}
{% if preview_ext in video_extensions %}
<video src="{{ preview_url }}" controls playsinline class="edit-media-preview"></video>
{% elif preview_ext in audio_extensions %}
@ -177,7 +177,7 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<div class="upload-thumbnail-item">
{% if thumbnail_key in s3_key_thumbnails %}
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ s3_key_thumbnails[thumbnail_key] %}
{% set get_endpoint = request.shop_cdn_endpoint ~ "/" ~ s3_key_thumbnails[thumbnail_key] %}
{% set thumb_ext = product.extensions.get(thumbnail_key, "") %}
{% if thumb_ext in video_extensions %}
<video src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" controls playsinline class="edit-media-preview"></video>

View file

@ -10,7 +10,7 @@
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>

View file

@ -4,7 +4,7 @@
<section class="one-column well">
<img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
<img class="logo" src="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
<br/>
<br/>

View file

@ -59,7 +59,20 @@
<br />
<br />
<fieldset>
<legend>Environment</legend>
<input type="radio" name="environment" id="env_production" value="0" checked />
<label for="env_production" class="inline-label">Production</label>
<input type="radio" name="environment" id="env_staging" value="1" />
<label for="env_staging" class="inline-label">Staging</label>
<input type="radio" name="environment" id="env_development" value="2" />
<label for="env_development" class="inline-label">Development</label>
</fieldset>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Create Shop" />
</form>
</section>

View file

@ -1165,6 +1165,120 @@ Existing sales honored for download buy purchasers.
</section>
<br />
<br />
<section class="one-column">
<section class="shop-settings well">
<h3>Environment Settings</h3>
<p>Non-production shops are hidden from search, feeds, and discovery. Use them to stage or test before going live.</p>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="environment-settings" />
<fieldset>
<legend>Environment</legend>
<input type="radio" name="environment" id="env_production" value="0" {% if environment == 0 %}checked{% endif %} />
<label for="env_production" class="inline-label">Production</label>
<input type="radio" name="environment" id="env_staging" value="1" {% if environment == 1 %}checked{% endif %} />
<label for="env_staging" class="inline-label">Staging</label>
<input type="radio" name="environment" id="env_development" value="2" {% if environment == 2 %}checked{% endif %} />
<label for="env_development" class="inline-label">Development</label>
</fieldset>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
</form>
</section>
</section>
<br />
<br />
<section class="one-column">
<section class="shop-settings well">
<h3>Storage Bucket (BYOB)</h3>
<p>Configure your own S3-compatible storage bucket. When enabled, all media uploads and CDN URLs will use your bucket instead of the default MPS storage.</p>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="bucket-settings" />
<label>
<input type="checkbox" name="primary_s3_enabled_checkbox"
{% if primary_s3_enabled %}checked{% endif %} />
Enable Custom Bucket
</label>
<br /><br />
<label for="primary_s3_endpoint">S3 Endpoint URL</label>
<input type="url" name="primary_s3_endpoint" id="primary_s3_endpoint"
value="{{ primary_s3_endpoint or '' }}"
placeholder="https://nyc3.digitaloceanspaces.com"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_region">Region</label>
<input type="text" name="primary_s3_region" id="primary_s3_region"
value="{{ primary_s3_region or '' }}"
placeholder="nyc3"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_bucket">Bucket Name</label>
<input type="text" name="primary_s3_bucket" id="primary_s3_bucket"
value="{{ primary_s3_bucket or '' }}"
placeholder="my-shop-media"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_access_key">Access Key</label>
<input type="text" name="primary_s3_access_key" id="primary_s3_access_key"
value="{{ primary_s3_access_key or '' }}"
placeholder="Access Key"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_secret_key">Secret Key</label>
<input type="password" name="primary_s3_secret_key" id="primary_s3_secret_key"
value="{{ primary_s3_secret_key or '' }}"
placeholder="Secret Key"
class="mps-text-input" />
<br /><br />
<label for="primary_s3_cdn_endpoint">CDN Endpoint URL</label>
<input type="url" name="primary_s3_cdn_endpoint" id="primary_s3_cdn_endpoint"
value="{{ primary_s3_cdn_endpoint or '' }}"
placeholder="https://my-shop-media.nyc3.cdn.digitaloceanspaces.com"
class="mps-text-input" />
<br /><br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
</form>
</section>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {

View file

@ -55,7 +55,7 @@
<span class="related-content-index">&#9654;</span>
<span class="related-content-item related-content-now-playing">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
{% else %}
<span class="related-content-no-thumb"></span>
{% endif %}
@ -65,7 +65,7 @@
data-product-id="{{ product.id }}"
data-title="{{ product.title }}"
data-url="{{ product.absolute_url(request) }}"
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
title="Add to queue">+</button>
</div>
{% endif %}
@ -73,7 +73,7 @@
<span class="related-content-index">{{ offset }}</span>
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
{% if "thumbnail1" in related.extensions %}
<img {% if offset > 7 %}loading="lazy" {% endif %}src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
<img {% if offset > 7 %}loading="lazy" {% endif %}src="{{ request.shop_cdn_endpoint }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
{% else %}
<span class="related-content-no-thumb"></span>
{% endif %}
@ -83,7 +83,7 @@
data-product-id="{{ related.id }}"
data-title="{{ related.title }}"
data-url="{{ related.absolute_url(request) }}"
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}"
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.shop_cdn_endpoint }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}"
title="Add to queue">+</button>
</div>
{% endfor %}

View file

@ -10,7 +10,7 @@
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>

View file

@ -4554,3 +4554,190 @@ class TestGiftCardFunctional(_AuthenticatedBase):
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Maximum must be greater than or equal to minimum", flash)
class TestEnvironmentSettings(_AuthenticatedBase):
"""MPS-14: Functional tests for environment settings."""
def test_change_to_staging(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "1",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Staging", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 1)
def test_change_to_development(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "2",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Development", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 2)
def test_change_back_to_production(self):
shop = self._create_shop_helper()
# First set to staging
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "1",
"submit": "Save Settings",
},
status=302,
)
# Then back to production
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "0",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Production", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 0)
def test_invalid_environment_value(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "99",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Invalid", flash)
self.dbsession.refresh(shop)
self.assertEqual(shop.environment, 0)
def test_environment_banner_shows_for_staging(self):
shop = self._create_shop_helper()
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "environment-settings",
"environment": "1",
"submit": "Save Settings",
},
status=302,
)
res = self.testapp.get(f"/s/{shop.id}/settings")
self.assertIn("STAGING ENVIRONMENT", res.text)
class TestBucketSettings(_AuthenticatedBase):
"""MPS-16: Functional tests for BYOB bucket settings."""
def test_enable_bucket_settings(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_enabled_checkbox": "on",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "my-test-bucket",
"primary_s3_access_key": "AKID123",
"primary_s3_secret_key": "SECRET456",
"primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Storage bucket settings updated", flash)
self.dbsession.refresh(shop)
self.assertTrue(shop.primary_s3_enabled)
self.assertEqual(shop.primary_s3_bucket, "my-test-bucket")
def test_enable_bucket_missing_fields(self):
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_enabled_checkbox": "on",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "",
"primary_s3_access_key": "",
"primary_s3_secret_key": "",
"primary_s3_cdn_endpoint": "",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("All bucket fields are required when enabling BYOB", flash)
self.dbsession.refresh(shop)
self.assertFalse(shop.primary_s3_enabled)
def test_disable_bucket(self):
shop = self._create_shop_helper()
# Enable first
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_enabled_checkbox": "on",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "my-test-bucket",
"primary_s3_access_key": "AKID123",
"primary_s3_secret_key": "SECRET456",
"primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
"submit": "Save Settings",
},
status=302,
)
# Then disable (checkbox not sent = off)
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "bucket-settings",
"primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
"primary_s3_region": "nyc3",
"primary_s3_bucket": "my-test-bucket",
"primary_s3_access_key": "AKID123",
"primary_s3_secret_key": "SECRET456",
"primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Storage bucket settings updated", flash)
self.dbsession.refresh(shop)
self.assertFalse(shop.primary_s3_enabled)

View file

@ -3493,3 +3493,133 @@ class TestGiftCard(unittest.TestCase):
self.assertEqual(gc.gift_email, "friend@test.com")
self.assertEqual(gc.gift_message, "Happy birthday!")
self.assertEqual(gc.purchaser_email, "buyer@test.com")
class TestShopEnvironment(unittest.TestCase):
"""MPS-14: Dev & Stage environment properties."""
def _make_shop(self, environment=0):
shop = Shop("env-test", "555-0000", "123 Test St", "test shop")
shop.environment = environment
return shop
def test_default_is_production(self):
shop = self._make_shop()
self.assertTrue(shop.is_production)
self.assertFalse(shop.is_non_production)
self.assertEqual(shop.environment_label, "Production")
def test_staging_environment(self):
shop = self._make_shop(environment=1)
self.assertTrue(shop.is_staging)
self.assertTrue(shop.is_non_production)
self.assertFalse(shop.is_production)
self.assertEqual(shop.environment_label, "Staging")
def test_development_environment(self):
shop = self._make_shop(environment=2)
self.assertTrue(shop.is_development)
self.assertTrue(shop.is_non_production)
self.assertFalse(shop.is_production)
self.assertEqual(shop.environment_label, "Development")
def test_unknown_environment_defaults_to_production_label(self):
shop = self._make_shop(environment=99)
self.assertEqual(shop.environment_label, "Production")
self.assertTrue(shop.is_non_production)
class TestShopTrial(unittest.TestCase):
"""MPS-15: 21-day free trial properties."""
def _make_shop(self, trial_started_ms=None, plan_active=False):
shop = Shop("trial-test", "555-0000", "123 Test St", "test shop")
shop.trial_started_timestamp = trial_started_ms
shop.plan_active = plan_active
return shop
def test_grandfathered_shop_is_active(self):
"""Pre-trial shops (NULL timestamp) are always active."""
shop = self._make_shop(trial_started_ms=None)
self.assertTrue(shop.is_active)
self.assertFalse(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertIsNone(shop.trial_expiry_timestamp)
@mock.patch("make_post_sell.models.shop.time")
def test_trial_active_within_21_days(self, mock_time):
import time as real_time
now_ms = int(real_time.time() * 1000)
# Started 10 days ago
started = now_ms - (10 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_ms=started)
mock_time.time.return_value = now_ms / 1000.0
self.assertTrue(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
self.assertGreater(shop.trial_days_remaining, 0)
@mock.patch("make_post_sell.models.shop.time")
def test_trial_expired_after_21_days(self, mock_time):
import time as real_time
now_ms = int(real_time.time() * 1000)
# Started 22 days ago
started = now_ms - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_ms=started)
mock_time.time.return_value = now_ms / 1000.0
self.assertFalse(shop.is_trial_active)
self.assertTrue(shop.is_trial_expired)
self.assertFalse(shop.is_active)
self.assertEqual(shop.trial_days_remaining, 0)
@mock.patch("make_post_sell.models.shop.time")
def test_paid_plan_overrides_trial(self, mock_time):
import time as real_time
now_ms = int(real_time.time() * 1000)
# Started 22 days ago but plan is active
started = now_ms - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_ms=started, plan_active=True)
mock_time.time.return_value = now_ms / 1000.0
self.assertFalse(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
def test_trial_expiry_timestamp(self):
shop = self._make_shop(trial_started_ms=1000000)
expected = 1000000 + (21 * 24 * 60 * 60 * 1000)
self.assertEqual(shop.trial_expiry_timestamp, expected)
class TestShopBYOB(unittest.TestCase):
"""MPS-16: Bring Your Own Bucket properties."""
def _make_shop(self, enabled=False, **kwargs):
shop = Shop("byob-test", "555-0000", "123 Test St", "test shop")
shop.primary_s3_enabled = enabled
shop.primary_s3_endpoint = kwargs.get("endpoint", "https://nyc3.digitaloceanspaces.com")
shop.primary_s3_region = kwargs.get("region", "nyc3")
shop.primary_s3_bucket = kwargs.get("bucket", "my-bucket")
shop.primary_s3_access_key = kwargs.get("access_key", "AKID")
shop.primary_s3_secret_key = kwargs.get("secret_key", "SECRET")
shop.primary_s3_cdn_endpoint = kwargs.get("cdn_endpoint", "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
return shop
def test_has_primary_s3_when_enabled_and_configured(self):
shop = self._make_shop(enabled=True)
self.assertTrue(shop.has_primary_s3)
def test_has_primary_s3_false_when_disabled(self):
shop = self._make_shop(enabled=False)
self.assertFalse(shop.has_primary_s3)
def test_has_primary_s3_false_when_missing_fields(self):
shop = self._make_shop(enabled=True, access_key="")
self.assertFalse(shop.has_primary_s3)
def test_media_cdn_endpoint_returns_custom_when_configured(self):
shop = self._make_shop(enabled=True)
self.assertEqual(shop.media_cdn_endpoint, "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
def test_media_cdn_endpoint_returns_none_when_not_configured(self):
shop = self._make_shop(enabled=False)
self.assertIsNone(shop.media_cdn_endpoint)

View file

@ -29,7 +29,7 @@ def content(request):
signed_get_object_url = None
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding,
@ -55,7 +55,7 @@ def content(request):
if content_type:
params["ResponseContentType"] = content_type
signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
@ -88,7 +88,7 @@ def content(request):
extension = product.extensions.get("product")
media_type = get_media_type(extension) if extension else None
if media_type in ("video", "audio"):
cdn_base = request.app["bucket.secure_uploads.get_endpoint"]
cdn_base = request.shop_cdn_endpoint
for track_name in ("instrumentals", "vocals"):
if track_name in product.extensions:
url = f"{cdn_base}/{product.s3_path}/{track_name}"

View file

@ -206,7 +206,7 @@ def sitemap_view(request):
"""Generate XML sitemap for the shop."""
shop = request.shop
if not shop:
if not shop or shop.is_non_production:
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'></urlset>")
response.content_type = "application/xml"
return response
@ -226,7 +226,7 @@ def rss_view(request):
"""Generate RSS 2.0 feed for the shop."""
shop = request.shop
if not shop:
if not shop or shop.is_non_production:
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><rss version='2.0'><channel></channel></rss>")
response.content_type = "application/xml"
return response
@ -245,7 +245,7 @@ def atom_view(request):
"""Generate Atom feed for the shop."""
shop = request.shop
if not shop:
if not shop or shop.is_non_production:
response = Response(body="<?xml version='1.0' encoding='UTF-8'?><feed xmlns='http://www.w3.org/2005/Atom'></feed>")
response.content_type = "application/xml"
return response

View file

@ -44,7 +44,7 @@ def player(request):
return HTTPBadRequest("Not a supported media file")
# Generate presigned URL
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@ -62,7 +62,7 @@ def player(request):
if content_type:
params["ResponseContentType"] = content_type
presigned_url = request.secure_uploads_client.generate_presigned_url(
presigned_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900, # 15 minutes
@ -127,7 +127,7 @@ def player(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
url = request.secure_uploads_client.generate_presigned_url(
url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
@ -192,7 +192,7 @@ def player_json(request):
return {"error": "Not a supported media file"}
# Generate presigned URL
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@ -209,7 +209,7 @@ def player_json(request):
if content_type:
params["ResponseContentType"] = content_type
presigned_url = request.secure_uploads_client.generate_presigned_url(
presigned_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
@ -259,7 +259,7 @@ def player_json(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
url = request.secure_uploads_client.generate_presigned_url(
url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,

View file

@ -44,7 +44,7 @@ def product(request):
signed_get_object_url = None
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
if (
request.user
@ -75,7 +75,7 @@ def product(request):
if content_type:
params["ResponseContentType"] = content_type
signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
@ -284,8 +284,8 @@ def product_edit(request):
product_modified = True
product.set_visibility(
visibility,
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
)
request.session.flash(("You updated the product's visibility.", "success"))
@ -297,7 +297,7 @@ def product_edit(request):
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
response = request.secure_uploads_client.head_object(
response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@ -325,9 +325,9 @@ def product_edit(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
request.secure_uploads_client.copy_object(
request.shop_uploads_client.copy_object(
ACL=acl,
Bucket=request.app["bucket.secure_uploads"],
Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
@ -342,8 +342,8 @@ def product_edit(request):
# Mirror to shop's custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.shop,
content_type=product.get_content_type(file_key),
@ -351,14 +351,14 @@ def product_edit(request):
)
# delete original upload key.
request.secure_uploads_client.delete_object(
request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
# get file size & store in our database.
response = request.secure_uploads_client.head_object(
Bucket=request.app["bucket.secure_uploads"],
response = request.shop_uploads_client.head_object(
Bucket=request.shop_bucket_name,
Key=f"{product.s3_path}/{file_key}",
)
@ -385,8 +385,8 @@ def product_edit(request):
is_video = (upload_media_type == "video")
ext = product.extensions.get(file_key)
sizes = process_karaoke(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.s3_path, is_video, ext,
public_key=shop.unsandbox_public_key,
@ -401,14 +401,14 @@ def product_edit(request):
product.file_bytes = tmp
request.dbsession.add(product)
request.dbsession.flush()
product.update_s3_acls(request.secure_uploads_client, request.app["bucket.secure_uploads"])
product.update_s3_acls(request.shop_uploads_client, request.shop_bucket_name)
# Mirror karaoke tracks to shop's custom bucket
if shop.has_s3_mirror:
from ..lib.s3_mirror import mirror_keys_async
mirror_keys_async(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
[f"{product.s3_path}/instrumentals", f"{product.s3_path}/vocals"],
shop,
)
@ -435,8 +435,8 @@ def product_edit(request):
["starts-with", "$key", key_starts_with],
]
signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
Bucket=request.app["bucket.secure_uploads"],
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
Bucket=request.shop_bucket_name,
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
@ -482,14 +482,14 @@ def product_edit(request):
if product.has_product_file:
try:
params = {
"Bucket": request.app["bucket.secure_uploads"],
"Bucket": request.shop_bucket_name,
"Key": product.s3_key,
"ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
}
content_type = product.get_content_type("product")
if content_type:
params["ResponseContentType"] = content_type
signed_product_url = request.secure_uploads_client.generate_presigned_url(
signed_product_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,

View file

@ -186,7 +186,28 @@ def shop_new(request):
request.session.flash(msg)
else:
# MPS-14: environment selector (default production)
environment = int(request.params.get("environment", "0"))
if environment not in (0, 1, 2):
environment = 0
# MPS-14: enforce 2 free dev/stage shops per production shop
if environment != 0:
prod_count = sum(1 for s in request.user.shops if s.is_production)
non_prod_count = sum(1 for s in request.user.shops if s.is_non_production)
allowed = prod_count * 2
if non_prod_count >= allowed:
request.session.flash((
"You need a production shop before creating dev/stage shops. "
"Each production shop includes 2 free dev/stage shops.",
"error",
))
return HTTPFound("/s/new")
import time as _time
shop = Shop(name, phone_number, billing_address, description)
shop.environment = environment
shop.trial_started_timestamp = int(_time.time() * 1000)
shop.add_user_to_shop(request.user)
request.user.set_active_shop(shop)
request.dbsession.add(shop)
@ -1053,6 +1074,69 @@ def shop_settings(request):
except (ValueError, TypeError):
request.session.flash(("Invalid gift card amount.", "error"))
# Handle environment settings (MPS-14)
if form_section == "environment-settings":
env_value = int(request.params.get("environment", "0"))
if env_value not in (0, 1, 2):
request.session.flash(("Invalid environment value.", "error"))
elif env_value != 0 and shop.environment == 0:
# Changing from production to non-production
shop.environment = env_value
request.session.flash((
f"Shop environment changed to {shop.environment_label}. "
"This shop is now hidden from public search and feeds.",
"success",
))
elif env_value == 0 and shop.environment != 0:
# Changing from non-production to production
shop.environment = env_value
request.session.flash((
"Shop environment changed to Production. "
"This shop is now publicly visible.",
"success",
))
elif env_value != shop.environment:
shop.environment = env_value
request.session.flash((
f"Shop environment changed to {shop.environment_label}.",
"success",
))
# Handle primary S3 bucket settings (MPS-16)
if form_section == "bucket-settings":
ps3_endpoint = request.params.get("primary_s3_endpoint", "").strip()
ps3_region = request.params.get("primary_s3_region", "").strip()
ps3_bucket = request.params.get("primary_s3_bucket", "").strip()
ps3_access_key = request.params.get("primary_s3_access_key", "").strip()
ps3_secret_key = request.params.get("primary_s3_secret_key", "").strip()
ps3_cdn_endpoint = request.params.get("primary_s3_cdn_endpoint", "").strip()
ps3_enabled = checkbox_to_bool(request.params.get("primary_s3_enabled_checkbox", "off"))
if ps3_enabled and not all([ps3_endpoint, ps3_region, ps3_bucket, ps3_access_key, ps3_secret_key, ps3_cdn_endpoint]):
request.session.flash(("All bucket fields are required when enabling BYOB.", "error"))
elif ps3_endpoint and not ps3_endpoint.startswith("https://"):
request.session.flash(("Bucket endpoint must start with https://", "error"))
elif ps3_cdn_endpoint and not ps3_cdn_endpoint.startswith("https://"):
request.session.flash(("CDN endpoint must start with https://", "error"))
else:
changed = False
for attr, val in [
("primary_s3_endpoint", ps3_endpoint),
("primary_s3_region", ps3_region),
("primary_s3_bucket", ps3_bucket),
("primary_s3_access_key", ps3_access_key),
("primary_s3_secret_key", ps3_secret_key),
("primary_s3_cdn_endpoint", ps3_cdn_endpoint),
]:
if getattr(shop, attr) != val:
setattr(shop, attr, val)
changed = True
if shop.primary_s3_enabled != ps3_enabled:
shop.primary_s3_enabled = ps3_enabled
changed = True
if changed:
request.session.flash(("Storage bucket settings updated.", "success"))
# If we processed any form submission, respond accordingly
if form_section:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
@ -1067,7 +1151,7 @@ def shop_settings(request):
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
response = request.secure_uploads_client.head_object(
response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@ -1096,9 +1180,9 @@ def shop_settings(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
request.secure_uploads_client.copy_object(
request.shop_uploads_client.copy_object(
ACL=acl,
Bucket=request.app["bucket.secure_uploads"],
Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
@ -1113,8 +1197,8 @@ def shop_settings(request):
# Mirror shop asset to custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
request.secure_uploads_client,
request.app["bucket.secure_uploads"],
request.shop_uploads_client,
request.shop_bucket_name,
f"{shop.id}/meta/{file_key}",
shop,
content_type=content_type,
@ -1122,7 +1206,7 @@ def shop_settings(request):
)
# delete original upload key.
request.secure_uploads_client.delete_object(
request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@ -1153,15 +1237,15 @@ def shop_settings(request):
["starts-with", "$key", key_starts_with],
]
signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
Bucket=request.app["bucket.secure_uploads"],
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
Bucket=request.shop_bucket_name,
# uploads to /<shop-uuid>/meta/shop-logo-banner.the-users-file.png
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
)
get_endpoints[file_key] = "{}/{}/meta/{}".format(
request.app["bucket.secure_uploads.get_endpoint"],
request.shop_cdn_endpoint,
shop.id,
file_key,
)
@ -1248,6 +1332,15 @@ def shop_settings(request):
"gift_card_enabled": shop.gift_card_enabled,
"gift_card_min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
"gift_card_max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
"environment": shop.environment,
"environment_label": shop.environment_label,
"primary_s3_endpoint": shop.primary_s3_endpoint or "",
"primary_s3_region": shop.primary_s3_region or "",
"primary_s3_bucket": shop.primary_s3_bucket or "",
"primary_s3_access_key": shop.primary_s3_access_key or "",
"primary_s3_secret_key": shop.primary_s3_secret_key or "",
"primary_s3_cdn_endpoint": shop.primary_s3_cdn_endpoint or "",
"primary_s3_enabled": shop.primary_s3_enabled,
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}

View file

@ -42,7 +42,7 @@ def watch_json(request):
media_type = get_media_type(extension) or "other"
# Generate presigned URL for media
bucket_name = request.app["bucket.secure_uploads"]
bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@ -59,7 +59,7 @@ def watch_json(request):
if content_type:
params["ResponseContentType"] = content_type
media_url = request.secure_uploads_client.generate_presigned_url(
media_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
@ -75,7 +75,7 @@ def watch_json(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
url = request.secure_uploads_client.generate_presigned_url(
url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
@ -94,7 +94,7 @@ def watch_json(request):
thumbnail_url = None
if "thumbnail1" in product.extensions:
thumbnail_url = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"{request.shop_cdn_endpoint}"
f"/{product.s3_path}/thumbnail1"
f"?ts={product.updated_timestamp}"
)
@ -132,7 +132,7 @@ def watch_json(request):
r_thumb = None
if "thumbnail1" in r.extensions:
r_thumb = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"{request.shop_cdn_endpoint}"
f"/{r.s3_path}/thumbnail1"
f"?ts={r.updated_timestamp}"
)
@ -164,7 +164,7 @@ def watch_json(request):
dl_params["ResponseContentType"] = dl_content_type
file_type_str = dl_content_type
file_size_str = product.human_file_bytes(file_key)
download_url = request.secure_uploads_client.generate_presigned_url(
download_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=dl_params,
ExpiresIn=900,
@ -174,7 +174,7 @@ def watch_json(request):
file_url = None
if has_product_file:
file_url = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"{request.shop_cdn_endpoint}"
f"/{product.s3_path}/{file_key}"
f"?ts={product.updated_timestamp}"
)