Skip to content

Commit 46d1500

Browse files
committed
🐛 FIX: review comments failed with 403 after serve restarted
Each start made a new token, so pages opened earlier were refused. The token now persists in the settings folder, stale pages fetch the current one and retry, and a failed save says so.
1 parent de3e525 commit 46d1500

5 files changed

Lines changed: 65 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ All notable changes to Wavelength. Versions follow semantic versioning.
5555
- `width` takes `monoBelow` (Hz): the low end below it is made mono and the rest keeps its width. The
5656
width effect's "narrowing removes" warning leaves out the low side signal it removes on purpose.
5757

58+
### Fixed
59+
- `wavelength serve`: comments and previews failed with 403 in a page opened before the server started
60+
again (after a rebuild or a login-item restart), because each start made a new token. The token is now
61+
kept in the settings folder (readable by this user only), a page whose token is stale fetches the
62+
current one and tries again, and a comment that can't be saved says so under the save button.
63+
5864
## [0.3.0] - 2026-09-26
5965

6066
Highlights: VST2 hosting (Intel-only plugins under Rosetta); DAWproject import and export (songs open

‎src/serve.cpp‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -227,15 +227,30 @@ std::string fnv(const std::string &s) {
227227
return std::string(buf).substr(0, 12);
228228
}
229229

230+
// The token pages send with every change. It lives in the settings folder (readable by this user only), so a
231+
// server started again (after a rebuild, by a login item) still accepts the pages already open; other sites
232+
// can never read it, which is all it guards against.
233+
std::string serveToken() {
234+
const fs::path f = platform::dataDir() / "serve-token";
235+
{
236+
std::ifstream in(f);
237+
std::string t;
238+
if (in >> t && t.size() == 32 && t.find_first_not_of("0123456789abcdef") == std::string::npos) return t;
239+
}
240+
std::random_device rd;
241+
std::mt19937_64 g(rd());
242+
char buf[33];
243+
std::snprintf(buf, sizeof buf, "%016llx%016llx", (unsigned long long)g(), (unsigned long long)g());
244+
std::error_code ec;
245+
fs::create_directories(f.parent_path(), ec);
246+
std::ofstream(f) << buf << "\n";
247+
fs::permissions(f, fs::perms::owner_read | fs::perms::owner_write, fs::perm_options::replace, ec);
248+
return buf;
249+
}
250+
230251
class Server {
231252
public:
232-
explicit Server(const ServeOptions &o) : opt_(o), root_(fs::absolute(o.root)) {
233-
std::random_device rd;
234-
std::mt19937_64 g(rd());
235-
char buf[33];
236-
std::snprintf(buf, sizeof buf, "%016llx%016llx", (unsigned long long)g(), (unsigned long long)g());
237-
token_ = buf;
238-
}
253+
explicit Server(const ServeOptions &o) : opt_(o), root_(fs::absolute(o.root)), token_(serveToken()) {}
239254
int run();
240255

241256
private:

‎ui/editor.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,4 @@ dialog.ed::backdrop { background: rgba(12, 14, 23, .6); backdrop-filter: blur(2p
6060
.ed-status { font-size: 12px; color: var(--accent); max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
6161
.ed-status.busy { color: var(--warn); }
6262
.ed-status.busy::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); margin-right: 6px; animation: pulse 1.2s infinite; }
63+
.ed-hint.err { color: var(--warn); }

‎ui/editor.js‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
sel: { b0: null, b1: null, tracks: new Set(), notes: [] }, loop: false, drag: null, userScrollAt: 0, follow: true,
1818
};
1919
let dlg, scroller, canvas, space, tip;
20+
const HINT = 'Saved to review.json in the song folder with the bars, tracks, notes and time you selected.';
2021

2122
/* ---------- markup ---------- */
2223
function build() {
@@ -57,7 +58,7 @@
5758
<h4>Comment for the agent</h4>
5859
<textarea id="ed-text" placeholder="What should change here? (Cmd+Enter to save)"></textarea>
5960
<div class="ed-actions"><button class="ed-btn primary" id="ed-save">Save comment</button></div>
60-
<div class="ed-hint">Saved to review.json in the song folder with the bars, tracks, notes and time you selected.</div>
61+
<div class="ed-hint">${HINT}</div>
6162
</section>
6263
<h4 style="padding:12px 14px 0;margin:0">Comments <span class="r" id="ed-count"></span></h4>
6364
<div class="ed-list" id="ed-list"></div>
@@ -575,12 +576,24 @@
575576
const r = reference();
576577
const body = { op: 'add', text, ref: r.ref || 'whole song', render: state.song.reportPath ? state.files[state.song.reportPath]?.[1] : null };
577578
for (const k of ['bars', 'beats', 'time', 'tracks', 'notes']) if (r[k]) body[k] = r[k];
578-
const res = await fetch('api/review?song=' + encodeURIComponent(state.slug), { method: 'POST', headers: postHeaders(), body: JSON.stringify(body) }).then(r => r.json());
579-
if (res.comments) { E.comments = res.comments; $d('#ed-text').value = ''; renderComments(); badge(); dirty(); }
579+
const res = await post(body);
580+
if (res?.comments) { E.comments = res.comments; $d('#ed-text').value = ''; renderComments(); badge(); dirty(); }
580581
}
581582
async function op(body) {
582-
const res = await fetch('api/review?song=' + encodeURIComponent(state.slug), { method: 'POST', headers: postHeaders(), body: JSON.stringify(body) }).then(r => r.json());
583-
if (res.comments) { E.comments = res.comments; renderComments(); badge(); dirty(); }
583+
const res = await post(body);
584+
if (res?.comments) { E.comments = res.comments; renderComments(); badge(); dirty(); }
585+
}
586+
// a change to review.json; a failure shows under the save button instead of vanishing (the text stays in the box)
587+
async function post(body) {
588+
const hint = $d('.ed-hint');
589+
try {
590+
const res = await postJson('api/review?song=' + encodeURIComponent(state.slug), body);
591+
hint.textContent = HINT; hint.classList.remove('err');
592+
return res;
593+
} catch (e) {
594+
hint.textContent = `Not saved: ${e.message}. Reload the page and save again.`; hint.classList.add('err');
595+
return null;
596+
}
584597
}
585598
function renderComments() {
586599
if (!dlg) return;

‎ui/index.html‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,22 @@ <h2>Files</h2>
330330
// Every source knows where it sits in the song: `lead` seconds of silence at the start of the file
331331
// (the mix's lead-in), then song time from `start`. A bar preview starts mid-song with no lead-in and
332332
// loops. The playhead, seeking and the editor all work in song seconds.
333-
const TOKEN = document.querySelector('meta[name="wavelength-token"]')?.content || '';
333+
let TOKEN = document.querySelector('meta[name="wavelength-token"]')?.content || '';
334334
function postHeaders() { return { 'Content-Type': 'application/json', 'X-Wavelength-Token': TOKEN }; }
335+
// POST JSON and read the answer. A 403 means this page's token is stale (a server from before 0.4 made a new
336+
// one each start): take the current one from a fresh copy of the page and try once more.
337+
async function postJson(url, body) {
338+
const send = () => fetch(url, { method: 'POST', headers: postHeaders(), body: JSON.stringify(body) });
339+
let r = await send();
340+
if (r.status === 403) {
341+
const page = await fetch('./', { cache: 'no-store' }).then(x => x.text()).catch(() => '');
342+
const m = page.match(/name="wavelength-token" content="([0-9a-f]+)"/);
343+
if (m && m[1] !== TOKEN) { TOKEN = m[1]; r = await send(); }
344+
}
345+
const j = await r.json().catch(() => ({}));
346+
if (!r.ok) throw new Error(j.error || `the server answered ${r.status}`);
347+
return j;
348+
}
335349
const songLead = () => state.song?.report?.leadIn ?? state.song?.job?.leadIn ?? 0;
336350
function songSecNow() { return audio.currentTime - (state.srcLead ?? songLead()) + (state.srcStart || 0); }
337351
function seekSongSec(s, play) {
@@ -422,9 +436,9 @@ <h2>Files</h2>
422436
state.pending = null; show(`Could not render ${spec.label}: ${r.error || r.status}`);
423437
};
424438
show(`Rendering ${spec.label}…`);
425-
fetch('api/preview', { method: 'POST', headers: postHeaders(), body: JSON.stringify({ song: slug, tracks: spec.tracks || [], from: spec.from || 0, to: spec.to || 0 }) })
426-
.then(r => r.json()).then(r => r.error ? (state.pending = null, show(`Could not render ${spec.label}: ${r.error}`)) : r.status === 'ready' ? done(r) : poll(r.id))
427-
.catch(() => { state.pending = null; show('The server did not answer'); });
439+
postJson('api/preview', { song: slug, tracks: spec.tracks || [], from: spec.from || 0, to: spec.to || 0 })
440+
.then(r => r.error ? (state.pending = null, show(`Could not render ${spec.label}: ${r.error}`)) : r.status === 'ready' ? done(r) : poll(r.id))
441+
.catch(e => { state.pending = null; show(`Could not render ${spec.label}: ${e.message || 'the server did not answer'}`); });
428442
drawTimeline();
429443
}
430444

0 commit comments

Comments
 (0)