Skip to content

Commit 73d64b2

Browse files
authored
Merge develop into main for 3.4.2-fork
Release 3.4.2-fork — repair the RSS feed wizard
2 parents d7e89ca + 80932c9 commit 73d64b2

34 files changed

Lines changed: 2140 additions & 448 deletions

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,46 @@ ones are marked like "v1.0.0-fork".
77

88
## [Unreleased]
99

10+
## [3.4.2-fork] - 2026-08-16
11+
12+
### Fixed
13+
14+
* **Finishing the RSS feed wizard saved nothing** (#262). The wizard's last
15+
step posted the finished feed to `/feeds/edit`, and that route became a
16+
redirect to the feeds manager in 3.4.0 when the duplicated server-rendered
17+
feeds list was retired. A redirect discards the body, so the wizard ran to
18+
completion and produced no feed. Affects 3.4.0 and 3.4.1. The manual "add a
19+
feed" tab and the curated-source browser were unaffected.
20+
* **The feed wizard showed no article to pick from.** Steps 2 and 3 render the
21+
fetched article so you can click the part to import, but the controller
22+
handed the view the extractor's whole result array instead of the article's
23+
HTML. The picker showed the word "Array", and the "Array to string
24+
conversion" notice behind it is fatal wherever PHP warnings are — so on those
25+
installs the wizard could not get past step 2 at all. Affects 3.4.0 and
26+
3.4.1. The article is also cached in the session again, as it was meant to
27+
be, so stepping back and forth no longer refetches it every time.
28+
29+
### Changed
30+
31+
* **The text editor and the feed forms save through `/api/v1`** (#262).
32+
Creating or editing a text now uses `POST /api/v1/texts` and
33+
`PUT /api/v1/texts/{id}`; the feed forms use `POST /api/v1/feeds` and
34+
`PUT /api/v1/feeds/{id}`. Both surfaces work against a configurable API base
35+
URL rather than the page origin, which is what a bundled mobile client needs.
36+
The text editor's "Check" button still asks the server for its parsing
37+
report, and the feed wizard's URL steps still drive the server-side session.
38+
39+
### Security
40+
41+
* **The language on a new text or feed is checked for ownership** (#262).
42+
`texts.TxLgID` and `news_feeds.NfLgID` have foreign keys to `languages`, but
43+
a foreign key proves the row exists, not that the caller owns it, and the
44+
form handlers passed the submitted value straight through. On a multi-user
45+
install a crafted request could file a text or feed under another user's
46+
language. The API endpoints these forms now use check ownership, and the
47+
form-POST routes that skipped the check are retired. Single-user installs
48+
were never affected.
49+
1050
## [3.4.1-fork] - 2026-08-12
1151

1252
### Fixed

ROADMAP.md

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,26 @@ Track the mobile-critical flows, not the file count.
110110
rendered by Alpine.js." So Phase 1 is **cutting the server-shell umbilical, not
111111
converting pages**.
112112

113-
**Status (re-audited 2026-08-07): complete.** Every mobile-critical surface is
114-
shell-free, and the legacy-fragment cleanup that was the last open item
115-
([#266](https://github.com/HugoFara/lwt/issues/266)) has shipped.
113+
**Status (re-audited 2026-08-07): complete *as scoped*.** Every mobile-critical
114+
surface is shell-free, and the legacy-fragment cleanup — the last open item in
115+
this phase — has shipped.
116+
117+
**This does not close [#266](https://github.com/HugoFara/lwt/issues/266).** That
118+
issue asks for PHP to emit "only data for hydration", which is a wider bar than
119+
Phase 1's. The *data* half is met (config blobs carry boot parameters —
120+
`['textId' => …, 'langId' => …]` — and everything else is fetched from
121+
`/api/v1`), but PHP still owns the markup half (audited 2026-08-08):
122+
123+
| PHP still owns | Where | Scale |
124+
| --- | --- | --- |
125+
| DOM scaffold | `x-data` in views | 53 views |
126+
| Which JS modules load | `PageLayoutHelper::getRequiredModules()``<meta name="lwt-modules">` | every page |
127+
| i18n injection | `PageLayoutHelper::buildI18nScript()` | every page |
128+
| Icons | `IconHelper::render()` → server-rendered `<i data-lucide>` | throughout |
129+
130+
The bundled client works around all four with a build-time transpiler
131+
(`build/php-view-prerender.mjs`) rather than removing them — see Phase 2. #266
132+
stays open against that markup half; it is not a Phase 1 deliverable.
116133

117134
- [x] **(Phase 0 gate) Injectable API base URL.** Done in Phase 0 — same seam.
118135
`@shared/api/client` resolves an injectable **absolute** server root and
@@ -203,10 +220,51 @@ shell-free, and the legacy-fragment cleanup that was the last open item
203220
keys were decorative, and the bare `books` entry allowed only GET and
204221
POST. `EndpointMethodReachabilityTest` now asserts real request shapes
205222
resolve, so the next drift fails a test instead of a feature.
223+
- [x] **Form POSTs — the write half, for the surfaces above**
224+
([#262](https://github.com/HugoFara/lwt/issues/262)). Those surfaces
225+
*read* from `/api/v1`; several still *wrote* by posting a form to the page
226+
origin, which a client pointed at a different server cannot do.
227+
Converted: tags, user profile/password/preferences,
228+
standalone term creation, the **text editor**
229+
(`POST /api/v1/texts`, `PUT /api/v1/texts/{id}` — neither endpoint
230+
existed; `TextsApi.create()` had been calling a 404 since it was written),
231+
and the **feed forms** (`POST`/`PUT /api/v1/feeds`).
232+
233+
Two mass-assignment holes closed on the way: `texts.TxLgID` and
234+
`news_feeds.NfLgID` are client-supplied references into `languages`, and
235+
a foreign key proves the row exists, not that the caller owns it. Both
236+
form handlers passed the submitted value straight to the facade; the API
237+
endpoints check ownership, and the form-POST routes that skipped it are
238+
retired. Multi-user installs only.
239+
240+
Fourteen views still post, down from sixteen: admin (4), the imports
241+
(`import_epub_form`, `upload_form`, `bulk_translate_form`), the feed
242+
wizard's navigation steps (3), the archived-text and check forms, the
243+
Microsoft link confirmation, and `edit_form` for its Check button alone.
244+
Every one of those is a surface this phase deliberately leaves
245+
server-rendered (below), so what remains of #262 is scoped by that list
246+
rather than by view count.
206247

207248
**Out of Phase 1** (leave server-rendered, fine in a WebView online): imports
208249
(file/web/youtube/whisper), admin/settings, language config, feeds.
209250

251+
Two exceptions inside that list, both because the work was a fix rather than a
252+
conversion:
253+
254+
- **The feed create/edit forms** went to `/api/v1` even though feeds are out of
255+
scope, because tracing them turned up that **finishing the RSS wizard had
256+
saved nothing since 2026-08-08** (`1e2216bb6`). Step 4 posted to
257+
`/feeds/edit`, and that commit made the route a 302 to the manager SPA when
258+
the duplicated feeds list was retired; a redirect discards the body. Present
259+
in 3.4.0 and 3.4.1. Verified both ways against a live BBC RSS feed: on the
260+
parent commit the walk ends with `news_feeds` empty, on the fix it writes the
261+
row. The wizard's *navigation* steps (2 and 3) still post to `/feeds/wizard`
262+
— they drive a server-side session state machine that renders HTML previews
263+
of the fetched page, and moving that to the client is its own project.
264+
- **The text editor's "Check" button** still posts. It asks for a
265+
server-rendered parsing report rather than saving, so it names its own target
266+
with `formaction` while the form itself carries no action.
267+
210268
**Definition of done per surface:** renders entirely from `/api/v1` JSON, no
211269
server-rendered partial carrying data, works against a configurable API base URL.
212270

cypress/e2e/05-texts.cy.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,58 @@ describe('Texts Management', () => {
153153
}
154154
});
155155
});
156+
157+
/**
158+
* The edit form saves through PUT /api/v1/texts/{id} rather than posting
159+
* itself (#262). Create the text first so the case does not depend on
160+
* whatever text 1 happens to be, then rename it and read the title back.
161+
*/
162+
it('should save an edit through the API', () => {
163+
const original = `Edit Me ${Date.now()}`;
164+
const renamed = `${original} (renamed)`;
165+
166+
startPastedText();
167+
cy.get('input[name="TxTitle"]').type(original);
168+
cy.get('textarea[name="TxText"]').type('Ein Satz. Noch ein Satz.');
169+
cy.get('button[name="op"][value="Save and Open"]').click();
170+
171+
cy.url()
172+
.should('match', /\/text\/\d+\/read/)
173+
.then((url) => {
174+
const textId = /\/text\/(\d+)\/read/.exec(url)?.[1];
175+
expect(textId, 'created text id').to.match(/^\d+$/);
176+
177+
cy.intercept('PUT', `**/api/v1/texts/${textId}`).as('saveText');
178+
179+
cy.visit(`/texts/${textId}/edit`);
180+
cy.get('input[name="TxTitle"]').should('have.value', original).clear();
181+
cy.get('input[name="TxTitle"]').type(renamed);
182+
cy.get('button[name="op"][value="Change"]').click();
183+
184+
// A form POST would never produce this request.
185+
cy.wait('@saveText').its('response.statusCode').should('eq', 200);
186+
187+
cy.visit(`/texts/${textId}/edit`);
188+
cy.get('input[name="TxTitle"]').should('have.value', renamed);
189+
});
190+
});
191+
192+
/**
193+
* "Check" is the one button still posting the form: it asks for a
194+
* server-rendered parsing report instead of saving.
195+
*/
196+
it('should still render the server-side check report', () => {
197+
cy.visit('/texts/1/edit');
198+
cy.get('body').then(($body) => {
199+
if ($body.text().includes('not found')) return;
200+
201+
cy.get('button[name="op"][value="Check"]').click();
202+
203+
// The report is the parse of the text: its sentences and its terms.
204+
cy.contains('h4', /sentences/i).should('exist');
205+
cy.get('[data-action="history-back"]').should('exist');
206+
});
207+
});
156208
});
157209

158210
describe('Archive Text', () => {

cypress/e2e/16-feeds.cy.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,83 @@ describe('Feeds', () => {
9595
});
9696
});
9797
});
98+
99+
/**
100+
* The manual "add a feed" form and the edit form save through /api/v1 rather
101+
* than posting themselves (#262).
102+
*/
103+
describe('feed forms', () => {
104+
it('creates a feed from the manual tab', () => {
105+
const name = `Manual Feed ${Date.now()}`;
106+
cy.intercept('POST', '**/api/v1/feeds').as('createFeed');
107+
108+
cy.visit('/feeds/new');
109+
cy.contains('a, button, .is-clickable', /manual/i).click();
110+
111+
cy.get('input[name="NfName"]').should('be.visible').type(name);
112+
cy.get('input[name="NfSourceURI"]').type('https://example.com/manual.xml');
113+
cy.get('input[name="NfArticleSectionTags"]').type('//div');
114+
cy.get('form').filter(':visible').contains('button[type="submit"]', /save/i).click();
115+
116+
cy.wait('@createFeed').its('response.statusCode').should('eq', 200);
117+
cy.location('pathname').should('match', /\/feeds\/\d+\/edit/);
118+
cy.get('input[name="NfName"]').should('have.value', name);
119+
});
120+
121+
it('saves an edit through the API', () => {
122+
const name = `Edit Feed ${Date.now()}`;
123+
const renamed = `${name} (renamed)`;
124+
125+
cy.apiRequest({
126+
method: 'POST',
127+
url: '/api/v1/feeds',
128+
body: {
129+
langId: 1,
130+
name,
131+
sourceUri: 'https://example.com/edit.xml',
132+
articleSectionTags: '//div',
133+
filterTags: '',
134+
options: 'edit_text=1'
135+
}
136+
}).then((response) => {
137+
const feedId = response.body.feed.id;
138+
cy.intercept('PUT', `**/api/v1/feeds/${feedId}`).as('updateFeed');
139+
140+
cy.visit(`/feeds/${feedId}/edit`);
141+
cy.get('input[name="NfName"]').should('have.value', name).clear();
142+
cy.get('input[name="NfName"]').type(renamed);
143+
cy.contains('button[type="submit"]', /update|save/i).click();
144+
145+
// A form POST would never produce this request — and /feeds/{id}/edit
146+
// no longer accepts one.
147+
cy.wait('@updateFeed').its('response.statusCode').should('eq', 200);
148+
cy.location('pathname').should('eq', '/feeds/manage');
149+
150+
cy.visit(`/feeds/${feedId}/edit`);
151+
cy.get('input[name="NfName"]').should('have.value', renamed);
152+
});
153+
});
154+
155+
it('no longer accepts a form POST on the page routes', () => {
156+
cy.request({ method: 'POST', url: '/feeds/new', failOnStatusCode: false })
157+
.its('status')
158+
.should('eq', 404);
159+
});
160+
161+
/**
162+
* The wizard's last step used to post to /feeds/edit. That route has
163+
* redirected to the manager since the server-rendered feeds list was
164+
* retired, so finishing the wizard discarded the feed. Walking the whole
165+
* wizard needs a live RSS URL, so what is asserted here is that the form
166+
* no longer targets the route that swallowed it.
167+
*/
168+
it('does not point the wizard finish at the retired route', () => {
169+
cy.request('/feeds/wizard?step=4').then((response) => {
170+
const html = String(response.body);
171+
expect(html).to.contain('x-data="feedWizardStep4"');
172+
expect(html).to.not.contain('action="/feeds/edit"');
173+
expect(html).to.not.contain('name="save_feed"');
174+
});
175+
});
176+
});
98177
});

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lwt",
3-
"version": "3.4.1",
3+
"version": "3.4.2",
44
"description": "Learning with Texts - A self-hosted language learning application for reading-based vocabulary acquisition",
55
"type": "module",
66
"main": "index.js",

src/Modules/Feed/Http/FeedEditController.php

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -62,31 +62,21 @@ public function spa(array $params): void
6262
/**
6363
* New feed form (wizard with 3 tabs: Browse, URL Wizard, Manual).
6464
*
65-
* Route: GET/POST /feeds/new
65+
* Route: GET /feeds/new
66+
*
67+
* Renders the scaffold only. The Browse and Manual tabs save through
68+
* POST /api/v1/feeds (#262), which is also the only path that checks the
69+
* submitted NfLgID belongs to the caller — the save branch that used to
70+
* live here passed it straight to the facade. The URL Wizard tab still
71+
* posts to /feeds/wizard, which drives a server-side session state machine
72+
* rather than saving a feed.
6673
*
6774
* @param array<string, string> $params Route parameters
6875
*
6976
* @return void
7077
*/
7178
public function newFeed(array $params): void
7279
{
73-
// Handle form submission before any output
74-
if (InputValidator::has('save_feed')) {
75-
$data = [
76-
'NfLgID' => InputValidator::getString('NfLgID'),
77-
'NfName' => InputValidator::getString('NfName'),
78-
'NfSourceURI' => InputValidator::getString('NfSourceURI'),
79-
'NfArticleSectionTags' => InputValidator::getString('NfArticleSectionTags'),
80-
'NfFilterTags' => InputValidator::getString('NfFilterTags'),
81-
'NfOptions' => rtrim(InputValidator::getString('NfOptions'), ','),
82-
];
83-
84-
$feedId = $this->feedFacade->createFeed($data);
85-
$this->flashService->success(__('feed.flash.created'));
86-
$this->redirect(url('/feeds/' . $feedId . '/edit'));
87-
return;
88-
}
89-
9080
// Clear wizard session if exists (must be before any output)
9181
if ($this->wizardSession->exists()) {
9282
$this->wizardSession->clear();
@@ -101,7 +91,10 @@ public function newFeed(array $params): void
10191
/**
10292
* Edit feed form.
10393
*
104-
* Route: GET/POST /feeds/{id}/edit
94+
* Route: GET /feeds/{id}/edit
95+
*
96+
* Renders the scaffold only; the form saves through
97+
* PUT /api/v1/feeds/{id} (#262).
10598
*
10699
* @param int $id Feed ID from route parameter
107100
*
@@ -117,23 +110,6 @@ public function editFeed(int $id): void
117110
return;
118111
}
119112

120-
// Handle form submission before any output
121-
if (InputValidator::has('update_feed')) {
122-
$data = [
123-
'NfLgID' => InputValidator::getString('NfLgID'),
124-
'NfName' => InputValidator::getString('NfName'),
125-
'NfSourceURI' => InputValidator::getString('NfSourceURI'),
126-
'NfArticleSectionTags' => InputValidator::getString('NfArticleSectionTags'),
127-
'NfFilterTags' => InputValidator::getString('NfFilterTags'),
128-
'NfOptions' => rtrim(InputValidator::getString('NfOptions'), ','),
129-
];
130-
131-
$this->feedFacade->updateFeed($id, $data);
132-
$this->flashService->success(__('feed.flash.updated'));
133-
$this->redirect(url('/feeds/manage'));
134-
return;
135-
}
136-
137113
$langName = $this->languageFacade->getLanguageName($feed['NfLgID']);
138114
PageLayoutHelper::renderPageStart('Edit Feed - ' . $langName, true);
139115

0 commit comments

Comments
 (0)