torrent security: opt-in gate + visibility gating to prevent accidental seeding

- Add torrent_opt_in boolean to mps_product (default false)
- Gate all torrent seeding (upload trigger, backfill) on torrent_opt_in AND visibility==1
- Suppress magnet/torrent display for all users when product is private or unlisted
- Add permanence warning and explicit consent checkbox on product edit page
- backfill_status endpoint counts only opted-in public products
- Alembic migration: 0ee5654cfe7d
This commit is contained in:
russell@unturf.com 2026-04-06 18:07:49 -04:00
parent 0d904e9e1c
commit ef372cecf3
6 changed files with 134 additions and 12 deletions

View file

@ -109,12 +109,18 @@ class Product(RBase, Base):
total_file_bytes = Column(BigInteger, nullable=False, default=0)
# Optional magnet link for BitTorrent distribution (set when shop.torrent_enabled)
# Optional magnet link for BitTorrent distribution.
# Only generated when torrent_opt_in=True AND visibility=1 (public).
torrent_magnet_link = Column(UnicodeText, nullable=True)
# CDN URL of the .torrent file (set alongside torrent_magnet_link)
torrent_file_url = Column(UnicodeText, nullable=True)
# Explicit per-product opt-in to BitTorrent seeding.
# False by default — owner must consciously enable per product.
# Once seeded, files persist in the swarm even if this is later disabled.
torrent_opt_in = Column(Boolean, nullable=False, server_default="0", default=False)
# a denormalized json field to relate products to bundles, and vice versa.
json_bundle_metadata = Column(
UnicodeText, default=DEFAULT_BUNDLE_METADATA, nullable=False

View file

@ -0,0 +1,37 @@
"""add torrent_opt_in column to mps_product
Revision ID: 0ee5654cfe7d
Revises: 4f4d7c147437
Create Date: 2026-04-06 18:03:53.663932
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0ee5654cfe7d'
down_revision = '4f4d7c147437'
branch_labels = None
depends_on = None
def _column_exists(table, column):
from alembic import op as _op
from sqlalchemy import inspect
bind = _op.get_bind()
insp = inspect(bind)
return column in [c["name"] for c in insp.get_columns(table)]
def upgrade():
if not _column_exists('mps_product', 'torrent_opt_in'):
op.add_column(
'mps_product',
sa.Column('torrent_opt_in', sa.Boolean(), server_default='0', nullable=False),
)
def downgrade():
if _column_exists('mps_product', 'torrent_opt_in'):
op.drop_column('mps_product', 'torrent_opt_in')

View file

@ -289,11 +289,28 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<br />
{% if torrent_enabled %}
<label>Torrent Distribution
<span style="font-weight:normal;color:#888">
— generated automatically when you upload the product file
</span>
<label>Torrent Distribution</label>
{% if torrent_seeded_but_private %}
<p style="color:var(--color-warning,#b45309);background:var(--color-warning-subtle,#fffbeb);border:1px solid currentColor;border-radius:var(--radius-md);padding:var(--space-3);margin:.5rem 0">
<strong>Active torrent — file cannot be recalled.</strong>
This product is no longer public, so the magnet link is hidden from visitors.
However, the file is already seeded in the BitTorrent network and will remain
available to anyone who already has the magnet link.
</p>
{% endif %}
<label style="font-weight:normal;display:flex;align-items:center;gap:.5rem;margin:.25rem 0">
<input type="checkbox" name="torrent_opt_in"
{% if torrent_opt_in %}checked{% endif %} />
Seed this product via BitTorrent
</label>
<p style="color:var(--color-text-muted,#888);font-size:.85em;margin:.25rem 0 .75rem">
Once seeded, the file persists in the BitTorrent network permanently —
it cannot be recalled even if you disable this or make the product private.
Only enable for files you intend to distribute publicly forever.
</p>
{% if torrent_magnet_link %}
<div style="display:grid;grid-template-columns:1fr auto auto auto;gap:.5rem;align-items:center">
<input type="text" id="torrent_magnet_link_display"
@ -309,9 +326,13 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<span></span>
{% endif %}
</div>
{% elif torrent_opt_in %}
<p style="color:#888;margin:.25rem 0">
Seeding enabled — magnet link will be generated when you upload the product file.
</p>
{% else %}
<p style="color:#888;margin:.25rem 0">
Upload the product file to generate the magnet link and .torrent automatically.
Enable seeding above, then upload the product file to generate a magnet link.
</p>
{% endif %}

View file

@ -110,6 +110,16 @@ def content(request):
"instrumentals_url": instrumentals_url,
"vocals_url": vocals_url,
"karaoke_eligible": karaoke_eligible,
"torrent_magnet_link": product.torrent_magnet_link if product.shop.torrent_enabled else None,
"torrent_file_url": product.torrent_file_url if product.shop.torrent_enabled else None,
# Only show torrent links on the public page when product is public.
# Private/unlisted products must not spread magnet links to visitors.
"torrent_magnet_link": (
product.torrent_magnet_link
if product.shop.torrent_enabled and product.visibility == 1
else None
),
"torrent_file_url": (
product.torrent_file_url
if product.shop.torrent_enabled and product.visibility == 1
else None
),
}

View file

@ -291,13 +291,39 @@ def product_edit(request):
request.shop_bucket_name,
)
request.session.flash(("You updated the product's visibility.", "success"))
# Warn if switching away from public while a torrent is already seeded.
if visibility != 1 and product.torrent_magnet_link:
request.session.flash((
"Warning: this product has an active torrent. "
"The magnet link is no longer shown to visitors, "
"but the file persists in the BitTorrent network and cannot be recalled.",
"warning",
))
if price != product.price:
product_modified = True
request.dbsession.add(product.set_price(price))
request.session.flash(("You updated the product's price.", "success"))
# torrent_magnet_link is auto-generated on upload — no manual save needed
# torrent_opt_in — explicit per-product seeding consent
if product.shop.torrent_enabled and "submit" in request.params:
new_opt_in = request.params.get("torrent_opt_in", "off") == "on"
if new_opt_in != product.torrent_opt_in:
product_modified = True
product.torrent_opt_in = new_opt_in
if new_opt_in:
request.session.flash((
"Torrent seeding enabled for this product. "
"The file will be seeded when you upload or re-upload. "
"Warning: once seeded, files cannot be recalled from the BitTorrent network.",
"warning",
))
else:
request.session.flash((
"Torrent seeding disabled. "
"Note: if already seeded, the file persists in the BitTorrent network.",
"success",
))
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
@ -381,8 +407,17 @@ def product_edit(request):
product.shop.id, request.registry["dbsession_factory"]
)
# Auto-generate .torrent + magnet link for the main product file
if file_key == "product" and product.shop.torrent_enabled:
# Generate .torrent + magnet link only when:
# - torrent distribution is enabled for the shop
# - the owner explicitly opted this product in (torrent_opt_in)
# - the product is public (visibility == 1)
# Torrents are permanent — files persist in the swarm once seeded.
if (
file_key == "product"
and product.shop.torrent_enabled
and product.torrent_opt_in
and product.visibility == 1
):
from ..lib.torrent import generate_torrent_async
generate_torrent_async(
request.shop_uploads_client,
@ -527,8 +562,12 @@ def product_edit(request):
"inventories": inventories if product.is_physical else {},
"price_history": price_history,
"torrent_enabled": product.shop.torrent_enabled,
"torrent_opt_in": product.torrent_opt_in,
"torrent_magnet_link": product.torrent_magnet_link or "",
"torrent_file_url": product.torrent_file_url or "",
"torrent_seeded_but_private": bool(
product.torrent_magnet_link and product.visibility != 1
),
}

View file

@ -79,6 +79,10 @@ def _torrent_backfill_async(request, shop):
continue # already generated
if "product" not in product.originals:
continue # no product file uploaded yet
if not product.torrent_opt_in:
continue # owner has not explicitly opted this product in
if product.visibility != 1:
continue # torrents are permanent — only seed public products
generate_torrent_async(
s3_client,
bucket,
@ -1513,7 +1517,12 @@ def torrent_backfill_status(request):
.all()
)
eligible = [p for p in products if "product" in p.originals]
eligible = [
p for p in products
if "product" in p.originals
and p.torrent_opt_in
and p.visibility == 1
]
done = [p for p in eligible if p.torrent_magnet_link]
total = len(eligible)