Commit graph

995 commits

Author SHA1 Message Date
09f9de4636 feat: deploy + reforge bust client ring cache (HTML + localStorage)
Two paths to stale client state, both closed:

1. HTML cache — content.py and product.py now send Cache-Control:
   no-store, must-revalidate on responses. Browsers were holding
   onto rendered sidebar HTML from before the pocket fix deployed,
   producing phantom 'this didn't work' reports.

2. localStorage cache — shop.json_discovery_ring + GIT_HASH are
   hashed into a short cache_version token, stamped on every page
   (<meta name='mps-cache-version'>) and every watch_json response.
   watch.js stores it in localStorage; on page load and every SPA
   nav, mismatch triggers removal of watchRing, watchRingPosition,
   watchRingHistory, watchRingLoops, watchQueue before anything
   reads them.

watch_json error responses (404 no media, 403 not public) also
carry cache_version so clients can flush even when the target
product can't be played.

Tests:
  - test_models.py TestCacheVersion: 6 unit tests (stability, ring
    content change, ring order change, empty ring, none shop,
    GIT_HASH flip via patch).
  - test_functional.py: 3 functional tests (content page sends
    no-store + meta tag, watch_json error carries cache_version,
    cache_version shifts after reforge).

All 370 model+integration tests + 8 new functional tests green.
2026-04-21 18:58:30 -04:00
2a9e993d5d bump GIT_HASH to 690f945 2026-04-21 18:30:37 -04:00
690f945f70 feat: validate_discovery_ring health check + mod-only diagnostic endpoint
Adds validate_discovery_ring(shop) in models/shop.py that returns a
dict diagnosing four ring topology defects:

  - duplicates: IDs appearing more than once in ring (greedy-walk bug)
  - orphans: public products missing from ring (added after reforge)
  - stale: ring IDs no longer public/present (deleted or unlisted
    after reforge — the 'pocket' condition we just patched)
  - length_mismatch: ring_length != public_count

Wired into reforge_discovery_ring_async — anomalies log a warning
after each background reforge, making silent drift visible.

New route /s/{shop_id}/ring/health.json exposes the validator to
shop mods (403 for anon and non-editor users, 404 for missing shop).

Tests across all three layers:
  - Unit (test_models.py, 7 tests): mocked shop.products, each
    anomaly class verified in isolation.
  - Integration (test_integration.py, 4 tests): real shop + products
    + reforge, simulates visibility changes and late additions,
    confirms reforge heals the ring.
  - Functional (test_functional.py, 5 tests): auth required, mod
    ownership enforced, 404 on unknown shop, real-world stale
    detection through the HTTP endpoint.
2026-04-21 18:30:31 -04:00
ace97a1ef6 bump GIT_HASH to 81a5de4 2026-04-21 18:20:35 -04:00
81a5de4ce1 fix: ring 'pocket' — fill gaps from deleted/unlisted products
get_ring_related_products walked exactly backward+forward positions
in the ring and silently dropped entries whose IDs no longer resolved
to visible products. Result: sparse offsets like [-2, -1, 1, 5, 28]
visible in Up Next — a 'pocket' of live items in an otherwise stale
ring slice.

New behavior: fetch every ring product once (bulk query), keep only
visibility==1, then walk further along the ring to collect the
requested backward/forward VALID neighbors. Offsets are renumbered
contiguously (-N..-1, 1..N). Pocket is filled by skipping past
deleted/unlisted entries until we have the requested count or
exhaust the ring.

Ring traversal on the client (ringPosition + direction) uses ring
indices directly and is unaffected — only the rendered Up Next
sidebar slice is densified.
2026-04-21 18:20:28 -04:00
3470d747f3 bump GIT_HASH to 2464d87 2026-04-21 18:12:41 -04:00
2464d8722b fix: cinema mode actually fills viewport width (override media-sizing rule)
Previous cinema CSS used width:auto which pinned small-resolution videos
to their natural size, leaving huge side-margins on wide displays.

Cinema explicitly wants edge-to-edge video: width:100%, height:auto,
max-height:92vh, object-fit:contain. This trades a small letterbox on
extra-wide viewports for real full-width rendering — intentional
override of the CLAUDE.md 'never combine width:100% with max-height'
rule, which exists to prevent dead whitespace on images. For cinema
the tradeoff is reversed: fox wants big video, accepts edge letterbox.
2026-04-21 18:12:35 -04:00
c5b0637d50 bump GIT_HASH to ee1d642 2026-04-21 17:36:59 -04:00
ee1d642d92 feat: cinema mode — full-width video, Up Next + Download beside comments
New Cinema toggle alongside Fresh/Reverse/Autoplay. When on, adds
.cinema-mode class to section.two-column, restructuring the grid:

  row 1: video (full viewport width, max 85vh, preserved aspect)
  row 2: description (full width)
  row 3: product-right (price + download + Up Next) | comments

Uses display:contents on .watch-left so watch-mode children bubble
up as direct grid items. Video sizing follows CLAUDE.md media rule
(width:auto + max-width:100% + max-height:85vh) to avoid letterbox
whitespace.

Preference persisted in localStorage (watchCinemaMode). Toggle
handler attached in rebindToggles; class applied on init and on
every toggle change.
2026-04-21 17:36:53 -04:00
78378f78d6 bump GIT_HASH to a2ffcdf 2026-04-21 14:38:04 -04:00
a2ffcdfea2 fix: karaoke button on its own row, separated from Prev/Random/Next
Karaoke toggle now spans full width of ring-header-controls grid,
pushing nav buttons (Prev/Random/Next) to their own 3-column row.
When karaoke is display:none (non-eligible shops), nav buttons
flow naturally into row 2. No template changes.
2026-04-21 14:37:53 -04:00
e055619037 bump GIT_HASH to b09d1fc 2026-04-21 14:23:11 -04:00
b09d1fc739 fix: watch-mode Next/autoplay follow one true ring, honor Fresh toggle
Every forward path through the ring now uses a single selector:
  chooseNextInRing() = freshMode ? getNextUnwatchedItem() : getNextItem()

Next button, autoplay countdown, DJ crossfade target, preload, and
countdown-play-now all route through it. Previously, Next button
hardcoded skipToNextUnwatched() regardless of Fresh toggle — user
would see offset +1 in sidebar but land on a farther unwatched item.

navigateToNext and completeDjFadeout now sync ringPosition via
indexOf(target) instead of blindly advancing by +direction — necessary
when Fresh mode jumps past watched items.
2026-04-21 14:22:59 -04:00
206ff49e68 docs: never broad-grep config files — rule for operation voyeur 2026-04-16 19:31:52 -04:00
e8d4c9a0d9 bump GIT_HASH to 7d7d4a3 2026-04-16 19:26:00 -04:00
7d7d4a371a fix: pass request.app (not registry.settings) to capture_karaoke_config
KeyError 'bucket.secure_uploads.region' in the detached karaoke child
on prod. production.ini stores keys with the app. prefix
(app.bucket.secure_uploads.region) and a request hook strips that
prefix into a dict attached as request.app. request.registry.settings
still carries the raw, prefixed keys.

The upload-time and on-demand karaoke call sites were passing
request.registry.settings; capture_karaoke_config expected the
stripped dict. Dev .ini happens to match both layouts which masked
this — prod raised KeyError and the response 502'd.

Switch both call sites to request.app (matches the pattern used by
backfill_karaoke_async and backfill_mirror_async) and document the
expected shape on capture_karaoke_config.
2026-04-16 19:25:48 -04:00
a9345bb7ea bump GIT_HASH to 806a97b 2026-04-16 15:45:37 -04:00
806a97baca fix: 502 on .flv/unknown-extension upload — use guess_type + guard None
ParamValidationError on copy_object after upload — ContentType=None
hit S3. get_content_type("thumbnail2") returned None for a .flv file
because mimetypes.types_map only holds Python's built-in table (no
.flv, .mkv, .opus, .m4v, etc.).

Switch to mimetypes.guess_type, which initializes from the OS mime
database and covers every common format. Keep None as the return for
truly unknown extensions so downstream callers that chain `or
"video/mp4"` still pick the right context-aware fallback.

Guard the two copy_object / mirror_key_async sites in views/product.py
with `or "application/octet-stream"` so even a genuinely unknown
extension can't crash the upload handler again.
2026-04-16 15:45:20 -04:00
d527b57b04 bump GIT_HASH to 8fcb4da 2026-04-16 15:08:41 -04:00
8fcb4da92c fix: run karaoke in detached child so upload response returns
Upload handler called process_karaoke synchronously — for audio/video
products, vocal isolation (download, ffmpeg, voxsplit, reupload) can
take several minutes. The response hung until karaoke finished, so
users saw a black screen after a successful upload while Caddy/uwsgi
timed out. The file itself was already in S3+DB, which is why hitting
Back showed the edit page with the upload present.

Fix: extract the fork+detach logic the on-demand path already used
(views/watch.py:karaoke_process) into a reusable helper,
process_karaoke_detached, in lib/karaoke.py. Both the upload path and
the on-demand path now hand off to the helper and return immediately.

The helper takes a snapshot of shop+S3+mirror config in the parent
(capture_karaoke_config) so the grandchild only touches the DB at the
end for the final metadata write — by then the parent has long since
committed. Mirror writes run synchronously in the grandchild instead
of via mirror_keys_async daemon threads that were dying at os._exit.
2026-04-16 15:05:20 -04:00
d757cbecbe fix(cart): enforce Stripe $0.50 minimum; add public sales stats
Stripe rejects charges under $0.50 — a $0.33 item was silently freed
because requires_payment threshold (64¢) skipped all card processing.

Changes:
- Cart + Invoice: add requires_stripe_payment (>= 50¢)
- cart_checkout GET + cart_complete_checkout POST: block Stripe when
  total < $0.50 and only Stripe is available; message points to DOGE/XMR
- Invoice Stripe charge loop: guard on requires_stripe_payment (safety net)
- Shop model: add public_sales_stats boolean (default False)
- shop_settings: toggle for public_sales_stats
- shop() view: include sales_count + sales_revenue when flag is on
- shop.j2: conditional stats section (sales count + revenue)
- Migration: 1a2b3c4d5e6f add_public_sales_stats_to_shop
2026-04-10 14:37:07 -04:00
8945ee106b bump GIT_HASH to 0a8400e 2026-04-07 12:54:09 -04:00
0a8400ecb2 fix: wrap script DB sessions in transaction.manager to resolve NoTransaction error
digest_sender cron was crashing with transaction.interfaces.NoTransaction.
bootstrap() doesn't start a transaction — pyramid_tm handles that for web
requests but not console scripts. Wrap DB work in transaction.manager context
instead of manual transaction.commit() calls.

Same latent defect fixed in backfill_karaoke.py.
2026-04-07 12:53:55 -04:00
0203172dcd fix test: patch _torrent_backfill_async directly, not generate_torrent_async 2026-04-07 10:47:33 -04:00
932a0dde6e fix: backfill functions return immediately, work runs in daemon thread
Both _torrent_backfill_async and _checksum_backfill_async were blocking
the request thread by iterating all products before returning — causing
the settings form to hang on "Saving...".

Fix: snapshot shop_id, bucket, S3 creds, and work list while the DB
session is live, then hand off to a single daemon thread and return.
The request redirects instantly.
2026-04-07 10:32:42 -04:00
3faf55203b torrent: index.html links thumbnails and main file with relative paths 2026-04-06 19:36:45 -04:00
ad82b812aa checksums: compute MD5+SHA-256 for every uploaded file, display on content page
- lib/checksums.py: background thread streams from S3, computes both digests
- models/product.py: checksums property + set_checksum stored in file_metadata["checksums"]
- views/product.py: trigger compute_checksums_async after every file upload
- views/shop.py: checksum backfill function (skips files already computed),
  checksum_backfill POST view, checksum_backfill_status JSON view
- routes.py: /s/{shop_id}/checksum-backfill + /s/{shop_id}/checksum-backfill-status
- templates/content.j2: <details> block near date metadata — SHA-256 then MD5
- templates/shop_settings.j2: Backfill Checksums button + progress bar with 4s polling
2026-04-06 18:18:33 -04:00
3671133b96 torrent: multi-file bundles, content/product distinction enforced
Paid products seed: preview + thumbnails + index.md + index.html
Free content seeds: content file + thumbnails + index.md + index.html
The paid product file is never included in any torrent.

- lib/torrent.py: redesign generate_torrent for directory bundles (torf multi-file)
  - description written as index.md + rendered index.html
  - torrent stored as bundle.torrent (was product.torrent)
  - build_bundle_files() encapsulates what belongs in each bundle type
- views/product.py: trigger fires on preview upload for paid products,
  product file upload for free content
- views/shop.py: backfill eligibility mirrors same rule; backfill_status too
- tests: rewrite TestTorrentLib for new bundle interface, add bundle_files tests
2026-04-06 18:14:47 -04:00
ef372cecf3 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
2026-04-06 18:07:49 -04:00
0d904e9e1c feat: torrent backfill progress bar on shop settings
Adds GET /s/{shop_id}/torrent-backfill-status JSON endpoint returning
total/done/remaining counts for products needing magnet link generation.

Shop settings page shows a progress bar + status line when torrent
distribution is enabled. Polls every 4 seconds, stops when done.
2026-04-06 17:56:02 -04:00
b72fc6bf3b feat: REST API v1 — HMAC-signed product/content creation and file upload
Adds a public/private key pair authentication system and REST API endpoints
for programmatic product and content management. Designed for CI/CD pipelines
(permacomputer.com image hosting).

Auth: HMAC-SHA256 signed requests using public/private key pairs.
The secret key never travels over the wire. Replay window: 300 seconds.

Endpoints:
  POST /api/v1/products              create product (fiat/crypto priced)
  POST /api/v1/content               create content (free)
  GET  /api/v1/products/{id}         get product
  GET  /api/v1/content/{id}          get content
  POST /api/v1/products/{id}/upload-url     presigned S3 POST for direct upload
  POST /api/v1/content/{id}/upload-url      presigned S3 POST for direct upload
  POST /api/v1/products/{id}/files/confirm  confirm upload, register metadata
  POST /api/v1/content/{id}/files/confirm   confirm upload, register metadata

Key management UI in shop settings. Secret shown once on generation.

Migration: mps_api_key table (id, shop_id, public_key, secret_key, label,
created_timestamp, last_used_timestamp, is_active)

Tests: 12 MpsApiKey unit tests, 8 REST API functional tests (269 total passing)
2026-04-06 15:23:30 -04:00
dc06551967 docs: use make migration — never hand-write revision IDs 2026-04-06 14:20:13 -04:00
c6d03bd9ae docs: clarify prod rules — restarts ok, some servers ci-only not salt 2026-04-06 13:45:29 -04:00
08509629b6 docs: never operate on production directly — always CI/CD and Salt 2026-04-06 13:44:50 -04:00
c12365143d fix: replace bad migration revision ID with proper alembic-generated ID
The torrent_distribution_support migration was manually created with a
duplicate revision ID (a1b2c3d4e5f6) already in use by an older migration,
and pointed to the wrong down_revision (fd9f7e2f2b78 root instead of
9884324a48e3 current head). This caused a cycle in the revision map and
alembic upgrade head failed silently, leaving prod with new code but old
schema → 502 on all shop endpoints.

Deleted the broken file, regenerated with alembic revision to get a proper
unique ID (c0236e351476), set correct down_revision, preserved idempotent
_column_exists guards.
2026-04-06 13:40:36 -04:00
d7e8ec5bc2 mps: torrent backfill on enable + web seed + full test coverage
- backfill: enabling torrent on a shop auto-generates .torrent for all
  existing products that have a product file (no manual trigger needed)
- web seed (BEP 19): CDN url embedded in .torrent + magnet link so
  clients bootstrap via HTTP then seed to peers (no seeder process needed)
- torrent_file_url: stored on Product, shown as download link on content
  and edit pages alongside the magnet link
- migration: idempotent _column_exists guards on all add_column calls
- template: grid layout (not flex) for magnet/torrent buttons on edit page
- tests: 13 passing tests covering all new paths (unit + functional)
  including backfill trigger, web seed construction, visibility gating
2026-04-05 22:09:20 -04:00
e06cf1c230 mps: torrent web seed + torrent_file_url for CDN-bootstrapped swarm
- embed CDN URL as BEP 19 web seed in .torrent + magnet link
- store torrent_file_url (CDN path to .torrent) on Product
- migration adds torrent_file_url column alongside magnet_link
- content page shows Magnet + .torrent download buttons
- product edit shows Copy Magnet + Open + .torrent download
- cdn_endpoint flows from request.shop_cdn_endpoint through async thread
2026-04-05 21:31:09 -04:00
7d78dff6ac mps: torrent magnet links auto-generated on file upload
lib/torrent.py: generate_torrent_async() — daemon thread that fires after
upload, downloads file from S3, builds .torrent via torf, uploads
product.torrent to S3 (public-read), saves magnet link to DB.

views/product.py: trigger generate_torrent_async when file_key=='product'
and shop.torrent_enabled. Removed manual magnet link save — it's automatic.

templates/product_edit.j2: magnet link is read-only + copy button + Open
button when generated; 'upload the product file' hint when not yet generated.

tests: updated to reflect auto-generation model, added content page test.
2026-04-05 20:03:47 -04:00
d5f55f8131 mps: torrent distribution — shop toggle + per-product magnet link
Shop settings: new 'Torrent Distribution' section with single checkbox
(torrent_enabled, default False). When on, product edit shows a magnet
link field; content page shows a Torrent button alongside Download.

- models/shop.py: torrent_enabled (Boolean, default False)
- models/product.py: torrent_magnet_link (UnicodeText, nullable)
- views/shop.py: torrent-settings form section handler
- views/product.py: save/validate magnet link on product edit
- views/content.py: pass torrent_magnet_link to content template
- templates/shop_settings.j2: Torrent Distribution section
- templates/product_edit.j2: magnet link field (shown when torrent enabled)
- templates/content.j2: Torrent button beside Download
- migration: a1b2c3d4e5f6 adds both columns
- tests: 6 functional tests covering enable/disable, magnet save,
  invalid scheme rejection, and no-leak when disabled
2026-04-05 19:57:44 -04:00
41a8525953 feat: add Previous button left of Random in watch controls 2026-04-03 10:00:10 -04:00
6ec646a075 fix: restore logo full width on mobile — max-height 100px, max-width 100% 2026-04-03 09:58:34 -04:00
f77a8029c1 bump GIT_HASH 2026-03-31 20:10:39 -04:00
5ceeaf8a69 feat: share watch queue across tabs via localStorage + storage events
Queue was sessionStorage (per-tab only). Switch to localStorage so any
tab can add to our shared queue. Storage events fire in all other same-origin
tabs on mutation — renderQueue() wired to keep UI in sync automatically.
2026-03-31 20:10:19 -04:00
f12fa036a6 style: avoid "the", use "our" — writing style rule + sweep 2026-03-31 13:20:21 -04:00
422d462b46 bump GIT_HASH 2026-03-30 09:26:44 -04:00
1883329af1 docs: mark bleach CWE-407 as fixed in security section 2026-03-30 09:26:42 -04:00
79ecef66ef bump GIT_HASH 2026-03-30 09:26:17 -04:00
c71fd328c3 fix: CWE-407 — limit HTML nesting depth before bleach to prevent O(2^N)
Add limit_html_nesting() to sanitize_html.py. Flattens any HTML element
nested beyond depth 20 using html.parser (O(N)) before the content reaches
bleach/html5lib. N=35 attack drops from 12.8s to 0.04s.

Wire into markdown_to_html() in render.py — single enforcement point for
all callers: product descriptions, shop descriptions, privacy policy,
terms of service, markup preview.

No byte cap — books, long-form content, and deep table-of-contents
structures are fully supported. The depth limit (20 levels) prevents the
exponential zone while accommodating any legitimate nesting depth.
2026-03-30 09:26:07 -04:00
4ef3059c8d bump GIT_HASH 2026-03-29 21:48:57 -04:00
72626bf65a fix: CWE-407 — cap markup preview input + harden color regex
markup_editor_preview: reject requests where data exceeds 100KB
before passing to markdown/bleach pipeline (same class as
CWE-407 bleach O(2^N) finding — no input cap on AJAX preview).

add_shop_theme_classes: replace single compound regex with four
anchored non-backtracking patterns to eliminate ReDoS risk on
color validation. Practical risk was low (theme_link_color is
always derived from color_scale()), but the pattern was unsafe.
2026-03-29 21:48:52 -04:00