feedBack/plugins/highway_3d/routes.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

306 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Plugin-registered FastAPI routes for the 3dhighway visualization plugin.
Registered by feedBack core via plugin.json's "routes" field — the
loader at plugins/__init__.py:589604 imports this module and calls
setup(app, context). context["config_dir"] points at the feedBack
data directory; we namespace user uploads under
{config_dir}/plugin_uploads/highway_3d/.
This module owns the upload/serve/delete endpoints for the `video` bg
style (issue #19 follow-up). Single deterministic slot — each upload
replaces the previous file, no orphan accumulation. localStorage on
the renderer side stores only the filename, never the bytes.
"""
import asyncio
import os
import re
import tempfile
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse
from starlette.datastructures import UploadFile
PLUGIN_ID = "highway_3d"
ALLOWED_VIDEO_EXTS = {"mp4", "webm"}
ALLOWED_VIDEO_MIMES = {"video/mp4", "video/webm"}
MAX_VIDEO_BYTES = 50 * 1024 * 1024 # 50 MB raw
# Filenames the GET endpoint accepts. Tightened to the exact slot
# pattern this plugin produces — anything else (leftover upload-*.part
# temp files from a crashed upload, future schema additions, manual
# disk edits) gets a 404 rather than being served. The previous
# permissive regex would have happily streamed a `.part` file to a
# client that knew the name.
SLOT_FILENAME_RE = re.compile(r"^current\.(mp4|webm)$")
def setup(app: FastAPI, context: dict) -> None:
config_dir = Path(context["config_dir"])
upload_dir = config_dir / "plugin_uploads" / PLUGIN_ID
upload_dir.mkdir(parents=True, exist_ok=True)
# Serialises the atomic replace + other-ext cleanup so two concurrent
# uploads of different extensions (e.g. mp4 and webm) can't both finish
# streaming before either cleans up, leaving both files on disk. Streaming
# itself (the slow part) happens outside the lock; only the final
# replace + cleanup is held under it — so concurrent uploads of the *same*
# extension still overlap for all but the last microsecond.
_slot_lock = asyncio.Lock()
@app.post(f"/api/plugins/{PLUGIN_ID}/files")
async def upload_file(request: Request):
# Pre-parse Content-Length guard — fires before ANY body reading.
#
# FastAPI only reads request.form() when the handler/dependency
# explicitly asks for it. By accepting `Request` directly (rather
# than `file: UploadFile = File(...)`), we get headers without
# consuming the body. If Content-Length already indicates the
# upload is too large, we return 413 immediately — python-multipart
# never buffers a byte to disk.
#
# Clients that omit or forge Content-Length fall through to the
# streaming chunk-count cap in _do_upload, which remains as a
# defence-in-depth fallback.
cl = request.headers.get("content-length")
if cl is not None:
try:
cl_int = int(cl)
except ValueError:
raise HTTPException(400, "Invalid Content-Length header.")
if cl_int < 0:
raise HTTPException(400, "Invalid Content-Length header.")
if cl_int > MAX_VIDEO_BYTES:
raise HTTPException(
413,
f"Upload exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB limit.",
)
# Body is only consumed here, after the Content-Length pre-check.
form = await request.form()
try:
file = form.get("file")
if not isinstance(file, UploadFile):
raise HTTPException(400, "Expected a file upload in field 'file'.")
try:
return await _do_upload(file)
finally:
try:
await file.close()
except Exception:
pass
finally:
# Release the form object (closes any remaining SpooledTemporaryFile
# references that weren't already closed by file.close() above).
try:
await form.close()
except Exception:
pass
async def _do_upload(file: UploadFile):
# Extension whitelist is the primary guard — file.filename
# (and thus the extension) is always present on a real upload,
# whereas content_type is unreliable: some OS / browser combos
# report it as empty or as the generic application/octet-stream
# for valid .mp4 / .webm files. This mirrors settings.html's
# client-side fallback so a working browser doesn't 400 here
# after passing the client check. Server-side raw decoding
# is left to the browser's <video> element on play.
ext = (Path(file.filename or "").suffix.lstrip(".") or "").lower()
if ext not in ALLOWED_VIDEO_EXTS:
raise HTTPException(400, "Filename must end in .mp4 or .webm.")
# MIME check applies only when the client supplied something
# specific. Empty / octet-stream / None mean "browser couldn't
# tell" — fall through to the extension whitelist that already
# passed above.
if (
file.content_type
and file.content_type != "application/octet-stream"
and file.content_type not in ALLOWED_VIDEO_MIMES
):
raise HTTPException(400, "Only MP4 and WebM are allowed.")
# Stream the body to a temp file so we never hold the full 50 MB
# in memory (and never doubled — the previous version buffered
# chunks AND a joined bytes object). Writes go through
# run_in_threadpool so the event loop isn't blocked by a
# multi-second disk write. Atomic os.replace at the end means
# a server crash mid-upload leaves the previous slot file
# intact rather than a half-written current.<ext>.
out_name = f"current.{ext}"
out_path = upload_dir / out_name
# mkstemp on the same filesystem as out_path is required for
# os.replace to be atomic. Suffix marks the partial so a stray
# leftover from a crashed upload is obvious on inspection.
fd, tmp_name = await run_in_threadpool(
tempfile.mkstemp, dir=str(upload_dir), prefix="upload-", suffix=".part"
)
tmp_path = Path(tmp_name)
bytes_read = 0
try:
# Wrap the raw fd in a Python file object so writes are
# guaranteed-complete: os.write can return a short write on
# some platforms / fd states and would silently truncate the
# upload. The buffered file object loops internally and
# raises on real errors.
#
# If fdopen itself fails, the fd hasn't been wrapped yet, so
# the outer try's tmpf.close path can't reach it — close
# manually here. The outer except still unlinks tmp_path.
try:
tmpf = await run_in_threadpool(os.fdopen, fd, "wb")
except BaseException:
try:
await run_in_threadpool(os.close, fd)
except OSError:
pass
raise
try:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
bytes_read += len(chunk)
if bytes_read > MAX_VIDEO_BYTES:
raise HTTPException(
413,
f"Video exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB cap.",
)
await run_in_threadpool(tmpf.write, chunk)
finally:
# Close before any rename / unlink to avoid Windows
# file-locking surprises. Close also flushes the
# buffer so the bytes are on disk before os.replace.
await run_in_threadpool(tmpf.close)
# Reject empty uploads before the atomic rename. Without
# this guard, a misbehaving client (or a multipart body
# whose file part is empty) would replace the slot with a
# 0-byte file that the renderer then tries — and fails —
# to play. Existing slot stays untouched; the temp file
# is cleaned up by the outer except.
if bytes_read == 0:
raise HTTPException(400, "Empty upload — file is 0 bytes.")
# Hold the slot lock for the atomic replace + other-ext
# cleanup. Streaming (above) happens outside the lock so
# concurrent uploads of different extensions can overlap
# for most of their duration. Only the final commit is
# serialised. Under the lock there are no concurrent
# writers, so we can safely delete the opposite slot
# without a snapshot — whichever upload acquires the lock
# second simply supersedes the first, and the first
# upload's cleanup (which already ran) may have removed
# the second's now-absent file, or the second's cleanup
# removes the first's file now. Either way at most one
# slot file survives after the lock is released.
async with _slot_lock:
await run_in_threadpool(os.replace, str(tmp_path), str(out_path))
for e in ALLOWED_VIDEO_EXTS - {ext}:
try:
await run_in_threadpool((upload_dir / f"current.{e}").unlink)
except OSError:
# Another process holding the file (antivirus,
# in-flight GET) shouldn't 500 the upload. The
# new file is already in place; the stale one
# will get retried on the next upload or Clear.
pass
except BaseException:
# Any failure (size cap, write error, even cancellation):
# remove the temp file so we don't leak partial uploads on
# disk. unlink_missing_ok would be cleaner but isn't on
# older Python versions.
try:
await run_in_threadpool(tmp_path.unlink)
except OSError:
pass
raise
return {
"url": f"/api/plugins/{PLUGIN_ID}/files/{out_name}",
"name": out_name,
"size": bytes_read,
}
@app.get(f"/api/plugins/{PLUGIN_ID}/files/{{filename}}")
async def get_file(filename: str):
if not SLOT_FILENAME_RE.match(filename):
raise HTTPException(404, "Not found.")
path = upload_dir / filename
# Defense-in-depth: even with the regex above, resolve and
# confirm the resolved path stays inside upload_dir. Catches
# any future regex regression or symlink trickery.
try:
resolved = path.resolve()
resolved.relative_to(upload_dir.resolve())
except (OSError, ValueError):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
raise HTTPException(404, "Not found.")
ext = resolved.suffix.lstrip(".").lower()
media = {"mp4": "video/mp4", "webm": "video/webm"}.get(
ext, "application/octet-stream"
)
# The slot URL is stable across re-uploads (we always overwrite
# current.<ext> in place), so without explicit cache headers a
# browser or upstream proxy will happily serve the previous
# video after a Replace operation. `no-cache` lets the cache
# store a copy but forces revalidation on every load — paired
# with the Last-Modified / ETag headers FileResponse adds, the
# browser sends If-Modified-Since and gets a 304 when unchanged.
return FileResponse(
resolved,
media_type=media,
headers={
"Cache-Control": "no-cache",
# nosniff prevents the browser from second-guessing the
# MIME we declared. The MIME is fixed to video/mp4 or
# video/webm by the slot pattern, but a malicious
# upload could try to sneak past via an allowed
# extension carrying e.g. HTML; nosniff keeps the
# browser from rendering it as anything other than a
# video stream.
"X-Content-Type-Options": "nosniff",
},
)
@app.delete(f"/api/plugins/{PLUGIN_ID}/files")
async def delete_files():
# Slot-level clear: removes every current.* in the upload dir
# regardless of extension. This is the only delete operation
# the client needs — the previous per-filename DELETE could
# leak the other extension's file (e.g. clearing current.mp4
# while current.webm survives) when client and server got out
# of sync about which slot was active.
#
# Best-effort + always 200: file-locking on Windows (an
# in-flight GET, antivirus, an OS file scanner) can transiently
# block unlink, and a 500 response would silently leave the
# client's localStorage in a "still has video" state because
# the UI doesn't update on a failed clear. The user's actual
# intent — "stop using this video" — is best served by
# returning success so the client clears its pointer and the
# next render uses the fallback style. Any leftover file
# comes back in `leftover` for visibility; operators or the
# next upload's pre-cleanup loop will handle it.
# Hold the slot lock so a concurrent upload's os.replace() can't
# sneak a new current.* into the slot between our glob and our
# unlink calls. Without the lock, a DELETE that interleaves with
# an upload could return "cleared" while the upload's replace
# commits a fresh file immediately after the unlink.
async with _slot_lock:
slot_paths = await run_in_threadpool(
lambda: list(upload_dir.glob("current.*"))
)
deleted = []
leftover = []
for path in slot_paths:
try:
await run_in_threadpool(path.unlink)
deleted.append(path.name)
except OSError:
leftover.append({"name": path.name, "error": "unlink failed"})
return JSONResponse({"ok": True, "deleted": deleted, "leftover": leftover})