Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions DATA_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ artifacts derived from permissively-licensed (CC-BY, public-domain) sources, and

- **Groove MIDI Dataset** — Gillick, Roberts, Engel, Eck, Bahdanau; Google Magenta,
2019. Licensed CC-BY 4.0. https://magenta.tensorflow.org/datasets/groove
- **Salamander Grand Piano** — Alexander Holm. Licensed CC-BY 3.0. The piano sample
set bundled at `frontend/public/samples/piano/` (the Tone.js-hosted subset) is
derived from this work. https://archive.org/details/SalamanderGrandPianoV3

## Note on the GPL and data

Expand Down
35 changes: 30 additions & 5 deletions backend/app/api/routes_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import secrets
import uuid
import json as _json_module
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel
Expand Down Expand Up @@ -1196,11 +1197,31 @@ def regenerate_part(req: RegeneratePartRequest):
return FileInfo(part=req.part, filename=filename, url=f"/exports/{req.generation_id}/{filename}")


# Generation ids are short uuid4 hex slices (see /generate); keep the check strict
# but tolerant of any hex/underscore/hyphen id we've ever minted.
_VALID_GEN_ID = re.compile(r'^[A-Za-z0-9_\-]{1,64}$')


def _safe_export_dir(gen_id: str) -> Path:
"""Resolve EXPORTS_DIR/<gen_id> and guarantee it stays inside EXPORTS_DIR.

Blocks path traversal via `..`/encoded separators in the id before any
filesystem access. Raises 422 for a malformed id, 404 if the dir is missing.
"""
if not _VALID_GEN_ID.match(gen_id):
raise HTTPException(status_code=422, detail="Invalid generation id")
root = EXPORTS_DIR.resolve()
target = (root / gen_id).resolve()
if not target.is_relative_to(root):
raise HTTPException(status_code=404, detail="Generation not found")
return target


@router.get("/exports/{gen_id}/bundle.zip")
def download_bundle(gen_id: str):
import zipfile
import io
output_dir = EXPORTS_DIR / gen_id
output_dir = _safe_export_dir(gen_id)
if not output_dir.exists():
raise HTTPException(status_code=404, detail="Generation not found")
mid_files = list(output_dir.glob("*.mid"))
Expand All @@ -1226,7 +1247,7 @@ def download_bundle(gen_id: str):
def download_sections(gen_id: str):
import zipfile
import io
sec_dir = EXPORTS_DIR / gen_id / "sections"
sec_dir = _safe_export_dir(gen_id) / "sections"
if not sec_dir.exists():
raise HTTPException(status_code=404, detail="No section stems found — generate in Arrangement mode first")
mid_files = list(sec_dir.glob("*.mid"))
Expand All @@ -1249,10 +1270,14 @@ def download_sections(gen_id: str):

@router.get("/exports/{gen_id}/{filename}")
def download_export(gen_id: str, filename: str):
file_path = EXPORTS_DIR / gen_id / filename
if not file_path.exists():
base = _safe_export_dir(gen_id).resolve()
file_path = (base / filename).resolve()
# Reject any filename that escapes the generation dir (e.g. `..%2f..%2fsecret`).
if not file_path.is_relative_to(base):
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(file_path), media_type="audio/midi", filename=filename)
return FileResponse(str(file_path), media_type="audio/midi", filename=file_path.name)


_SAFE_PATH = re.compile(r'^[a-zA-Z0-9_\-]{1,80}$')
Expand Down
17 changes: 6 additions & 11 deletions backend/app/api/routes_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from app.services.style_loader import list_styles, get_style_detail, save_custom_style, load_style
from app.models.schemas import StyleInfo
from app.models.schemas import StyleInfo, CustomStyleRequest

router = APIRouter()

Expand Down Expand Up @@ -93,14 +93,9 @@ def _build_preview(style_id: str) -> bytes:


@router.post("/styles/custom")
def create_custom_style(body: dict):
style_id = body.get("id", "")
if not _VALID_ID.match(style_id):
def create_custom_style(body: CustomStyleRequest):
# Pydantic validates the core fields + bounds; still re-check the id against
# the strict filename pattern, since it becomes a path on disk.
if not _VALID_ID.match(body.id):
raise HTTPException(status_code=422, detail="Style id must be 1-40 lowercase alphanumeric/underscore chars")
if not body.get("name"):
raise HTTPException(status_code=422, detail="Style name is required")
required = ("bpm_range", "default_scale", "progression_templates")
for field in required:
if field not in body:
raise HTTPException(status_code=422, detail=f"Missing required field: {field}")
return save_custom_style(body)
return save_custom_style(body.model_dump())
18 changes: 15 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,26 @@ async def lifespan(app: FastAPI):

app = FastAPI(title="GenreGrid API", version="0.1.0", lifespan=lifespan)

_cors_env = os.environ.get("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
# A packaged renderer runs on a random 127.0.0.1:<port>, so we can't pin a fixed
# origin — but the API must NOT be open to arbitrary websites (a page the user
# visits in their browser could otherwise POST to the local backend). Allow any
# localhost / 127.0.0.1 origin (any port) via regex: same-machine requests keep
# working, while a browser blocks evil.com's cross-origin JSON POSTs at preflight.
# `CORS_ORIGINS="*"` (the old packaged setting) now maps to this safe localhost
# regex rather than a literal wildcard; an explicit comma-list is still honored.
_LOCALHOST_ORIGIN_RE = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"
_cors_env = os.environ.get("CORS_ORIGINS", "").strip()

if _cors_env and _cors_env != "*":
_cors_kwargs = {"allow_origins": [o.strip() for o in _cors_env.split(",") if o.strip()]}
else:
_cors_kwargs = {"allow_origin_regex": _LOCALHOST_ORIGIN_RE}

app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_methods=["*"],
allow_headers=["*"],
**_cors_kwargs,
)

app.include_router(health_router)
Expand Down
27 changes: 26 additions & 1 deletion backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,35 @@
# Foundation, either version 3 of the License, or (at your option) any later
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional


class CustomStyleRequest(BaseModel):
"""A user-authored style saved via POST /styles/custom.

Validates the fields the generators actually read (with bounds), while
`extra="allow"` keeps richer per-style config (drums, voices, feel …) that
the StyleEditor sends. The route also re-checks `id` against a strict
filename pattern before it becomes a path on disk.
"""
model_config = {"extra": "allow"}

id: str = Field(min_length=1, max_length=40)
name: str = Field(min_length=1, max_length=80)
bpm_range: List[int] = Field(min_length=2, max_length=2)
default_scale: str = Field(min_length=1, max_length=40)
progression_templates: List[List[str]] = Field(min_length=1, max_length=64)

@field_validator("bpm_range")
@classmethod
def _bpm_range_sane(cls, v: List[int]) -> List[int]:
lo, hi = v[0], v[1]
if not (20 <= lo <= hi <= 300):
raise ValueError("bpm_range must be [low, high] within 20–300 and low ≤ high")
return v


class GenerateRequest(BaseModel):
style_id: str
key: str = "C"
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/test_api_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,40 @@ def test_generate_loop_over_http():
assert stem.status_code == 200 and stem.content[:4] == b"MThd"


def test_cors_allows_localhost_but_not_external_sites():
# A random-port renderer origin (as in the packaged app) is granted
r = client.options("/generate", headers={
"Origin": "http://127.0.0.1:53311", "Access-Control-Request-Method": "POST"})
assert r.headers.get("access-control-allow-origin") == "http://127.0.0.1:53311"

# An arbitrary website the user might visit is NOT granted access
r = client.options("/generate", headers={
"Origin": "https://evil.example", "Access-Control-Request-Method": "POST"})
assert r.headers.get("access-control-allow-origin") is None


def test_export_download_rejects_path_traversal():
# A generation whose stems are genuinely downloadable
r = client.post("/generate", json={
"style_id": "lofi", "key": "C", "scale": "major", "bpm": 90, "bars": 4,
"parts": ["chords"], "mode": "loop", "seed": 9, "use_priors": False,
})
assert r.status_code == 200
gid = r.json()["generation_id"]

# A real stem still downloads (regression guard for the hardened route)
assert client.get(f"/exports/{gid}/chords.mid").status_code == 200

# Encoded traversal in the filename must not escape the generation dir
for evil in ("..%2f..%2fmeta.json", "..%2F..%2F..%2Fetc%2Fpasswd", "%2e%2e%2fchords.mid"):
resp = client.get(f"/exports/{gid}/{evil}")
assert resp.status_code in (404, 422), evil
assert b"root:" not in resp.content

# A malformed generation id is rejected before any filesystem access
assert client.get("/exports/..%2f..%2fetc/passwd").status_code in (404, 422)


def test_song_lifecycle_over_http(song):
gid = song["generation_id"]
assert song["sections"][-1]["section_type"] == "ending"
Expand Down
102 changes: 102 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# GenreGrid Roadmap

A living, prioritized roadmap from the July 2026 project survey. Check items off as
they land; add new findings under the right phase so nothing gets lost.

**Status legend:** `[ ]` todo · `[~]` in progress · `[x]` done · `[-]` won't do / obsolete

_Last updated: 2026-07-23 — Phase 1 complete (see Done log)._

---

## Health snapshot (at survey time)

- Backend: ~14.3k LoC Python, **132 tests passing**, ruff gate (E/F/W) configured.
- Frontend: ~11.3k LoC TS/Vue, **12 tests** (3 files) — thin coverage.
- Electron: `contextIsolation: true`, `nodeIntegration: false` — securely configured.
- Overall: a well-built project. Items below are refinements, not firefighting.

---

## Phase 1 — Harden (high value, low risk)

- [x] **Path traversal in `download_export`** — hardened all three export routes with a
containment check (`_safe_export_dir` + `is_relative_to`) and a strict id pattern; new
regression test. → `backend/app/api/routes_generate.py`
- [x] **CORS `*` in the packaged app** — replaced with a localhost/127.0.0.1 origin regex
(covers the renderer's random port, blocks external sites at preflight). Electron no
longer sets `CORS_ORIGINS: '*'`. → `backend/app/main.py`, `frontend/electron/main.ts`
- [ ] _Optional follow-up:_ per-session token as defense-in-depth on mutating JSON
endpoints. Deferred — a header token complicates file-URL/audio loading; the origin
allowlist already closes the browser-based attack.
- [x] **`will-navigate` / `setWindowOpenHandler` guards** added — in-app navigation is
same-origin only; external links open in the OS browser. → `frontend/electron/main.ts`
- [x] **`save-temp-file` IPC** now `path.basename()`s the renderer-supplied filename.
→ `frontend/electron/main.ts`
- [x] **`create_custom_style`** now validates via `CustomStyleRequest` (typed core fields
+ bounds, `extra="allow"` for rich style config). → `backend/app/models/schemas.py`
- [x] **Bundle the Salamander piano samples locally** — the 30-file sample set now lives
in `frontend/public/samples/piano/` (2 MB) and `loader.ts` loads `/samples/piano/`, so
the flagship piano works fully offline and no longer fetches from `tonejs.github.io`.
Attribution (CC-BY 3.0, Alexander Holm) added to `DATA_LICENSES.md`.
→ `frontend/src/soundfonts/loader.ts`

## Phase 2 — Sound polish

- [ ] **Master bus glue/limiting** — master is a unity `Gain` (a `DynamicsCompressor`
renders silence on Linux Electron), so a full arrangement can peak/clip with nothing
catching it. Add a Linux-safe JS soft-clip / look-ahead limiter.
→ `frontend/src/soundfonts/loader.ts:39`
- [ ] **Velocity layers / round-robins in sample sets** — ~4 MB total, ~one mp3 per
note-zone; wide pitch-shifting sounds synthetic. More velocity layers = fastest
realism gain. → `frontend/public/samples/`
- [ ] **Loudness normalization across styles** — target an LUFS level per style so
perceived volume doesn't jump between styles.
- [ ] **Per-style FX presets** — reverb/chorus/delay are currently shared and fixed.
→ `frontend/src/soundfonts/loader.ts` (buses)

## Phase 3 — Performance & refactor

- [ ] **Parallelize full-song section generation** — song builds take ~1–2 min and
sections generate serially though they're embarrassingly parallel. SSE progress
plumbing (`generate-stream`) already exists.
→ `backend/app/api/routes_song.py`
- [ ] **Extract song/arrangement logic out of route god-files into `services/`** —
handlers should be thin. → `routes_song.py` (1498), `routes_generate.py` (1299)
- [ ] **Split `useMidiPlayer.ts`** (1018 lines) into focused composables.
- [ ] **Log swallowed exceptions** — 27 broad/bare `except`; several return silently
(e.g. `record_export_keep`). Add logging.
- [ ] **Grow frontend test coverage** — 12 tests vs 132 backend; the audio scheduler and
WAV-render path (most fragile code) are untested.
- [ ] **Clear deprecation warnings** — Starlette `TestClient` httpx warning; Electron
numeric-level `console-message` signature. → `frontend/electron/main.ts:206`

## Phase 4 — Features

- [ ] **Expose the data-driven priors toggle in the UI** — wired end-to-end already, no
frontend control yet. Low effort.
- [ ] **Non-4/4 time signatures** (6/8, 3/4, 7/8) — currently hardcoded `numerator=4`.
→ `backend/app/services/midi_writer.py:162`
- [ ] **Custom soundfont / SF2 upload** + per-part instrument picker.
- [ ] **Surface WAV/offline-audio export more prominently** — already implemented
(OfflineAudioContext + render queue); just under-discovered.
- [ ] **Tempo automation & mid-song modulation** — partial today (`chorus_key_shift`).

---

## Done log

- **2026-07-23 — Phase 1 security hardening** (uncommitted working changes):
- Export downloads hardened against path traversal (`_safe_export_dir` containment
check on `/exports/{gen_id}/bundle.zip`, `/sections.zip`, and `/{filename}`).
- CORS locked to a localhost-only origin regex; dropped the packaged `CORS_ORIGINS=*`.
- Electron `will-navigate` + `setWindowOpenHandler` guards; `save-temp-file` basenames
the filename.
- `POST /styles/custom` validated with a `CustomStyleRequest` model.
- Salamander piano bundled locally (`public/samples/piano/`, 2 MB) — flagship piano is
now fully offline; CC-BY 3.0 attribution added to `DATA_LICENSES.md`.
- Tests: +3 (traversal rejection, CORS allow/deny). Suite 131→134 green; ruff clean;
frontend `vue-tsc` + vitest clean.

**Phase 1 complete.** Next up: Phase 2 (sound polish) — master limiter is the natural
first step and pairs well with the now-local piano.
36 changes: 33 additions & 3 deletions frontend/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
* <https://www.gnu.org/licenses/> for details.
*/
import { app, BrowserWindow, dialog, ipcMain, nativeImage, protocol, net as electronNet } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, nativeImage, protocol, shell, net as electronNet } from 'electron'
import { spawn, ChildProcess } from 'child_process'
import nodeNet from 'net'
import http from 'http'
Expand Down Expand Up @@ -100,7 +100,10 @@ async function startBackend(): Promise<void> {
const logFile = fs.openSync(path.join(logDir, 'backend.log'), 'w')

backendProcess = spawn(exePath, [String(backendPort)], {
env: { ...process.env, GENREGRID_DATA_DIR: dataDir, CORS_ORIGINS: '*' },
// No CORS_ORIGINS override: the backend defaults to a localhost-only origin
// regex, which covers the renderer's random 127.0.0.1 port while keeping the
// local API closed to arbitrary websites.
env: { ...process.env, GENREGRID_DATA_DIR: dataDir },
stdio: ['ignore', logFile, logFile],
// The backend is a console-subsystem exe (so devs can run it standalone
// and see output); CREATE_NO_WINDOW keeps its blank console from popping
Expand Down Expand Up @@ -201,6 +204,30 @@ async function createWindow(): Promise<void> {
},
})

// ── Navigation hardening ──────────────────────────────────────────────────
// The renderer only ever needs to live at its own origin (the Vite dev server
// or the local static server). Anything that tries to navigate the window
// elsewhere, or open a new window/popup, is either a bug or a compromised
// renderer — block in-app navigation and hand external URLs to the OS browser.
const rendererOrigin = () => {
try { return new URL(VITE_DEV_SERVER_URL || 'http://127.0.0.1').origin } catch { return '' }
}
win.webContents.on('will-navigate', (e, url) => {
// Allow same-origin (reloads, SPA); deny everything else.
let sameOrigin = false
try { sameOrigin = new URL(url).origin === new URL(win.webContents.getURL()).origin } catch { /* deny */ }
if (!sameOrigin) {
e.preventDefault()
if (/^https?:/.test(url)) shell.openExternal(url)
console.warn('[main] blocked in-app navigation to', url)
}
})
win.webContents.setWindowOpenHandler(({ url }) => {
// Never spawn Electron child windows; open real web links in the OS browser.
if (/^https?:/.test(url) && new URL(url).origin !== rendererOrigin()) shell.openExternal(url)
return { action: 'deny' }
})

// ── DEBUG: mirror the whole renderer console into the main-process stdout, so the full
// renderer log (audio setup, errors, everything) shows in the terminal without DevTools.
win.webContents.on('console-message', (_e, level, message) => {
Expand Down Expand Up @@ -352,7 +379,10 @@ ipcMain.on('get-api-port', (event) => {
ipcMain.handle('save-temp-file', async (_, { filename, data }: { filename: string; data: number[] }) => {
const dir = path.join(app.getPath('temp'), 'genregrid')
await fs.promises.mkdir(dir, { recursive: true })
const filePath = path.join(dir, filename)
// Strip any directory components — the renderer supplies this name, and a
// value like `../../evil` must not let a write escape the temp dir.
const safeName = path.basename(filename) || 'stem.mid'
const filePath = path.join(dir, safeName)
await fs.promises.writeFile(filePath, Buffer.from(data))
return filePath
})
Expand Down
Binary file added frontend/public/samples/piano/A0.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A1.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A2.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A3.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A4.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A5.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A6.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/A7.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C1.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C2.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C3.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C4.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C5.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C6.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C7.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/C8.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds1.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds2.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds3.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds4.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds5.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds6.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Ds7.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs1.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs2.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs3.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs4.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs5.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs6.mp3
Binary file not shown.
Binary file added frontend/public/samples/piano/Fs7.mp3
Binary file not shown.
7 changes: 5 additions & 2 deletions frontend/src/soundfonts/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
*/
import * as Tone from 'tone'

// Salamander Grand Piano — same sample set used in Tone.js official examples
const BASE_URL = 'https://tonejs.github.io/audio/salamander/'
// Salamander Grand Piano — bundled locally under public/samples/piano/ (served by
// the renderer's static server) so the flagship piano works fully offline and
// doesn't fetch from a third-party host at runtime. Same sample set as the Tone.js
// examples; served identically to the other instruments' /samples/... sets.
const BASE_URL = '/samples/piano/'

const SAMPLE_MAP: Record<string, string> = {
A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3',
Expand Down