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:
parent
0d904e9e1c
commit
ef372cecf3
6 changed files with 134 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue