Releases: HugoFara/learning-with-texts
Release list
LWT 3.6.0: Imported terms you can read, and forms that became an API
A dictionary import no longer leaves your texts unreadable, the feed wizard stops reloading the page four times over, and checking a text keeps whatever you were editing. Most of the work underneath is the move off same-origin form POSTs.
A dictionary import no longer leaves every word unmarked
Importing a dictionary created one term per entry and never linked them to the words in your texts. The reader decides a word is unknown from that link rather than from whether the term exists, so imported terms stayed invisible: the word kept its unmarked colour, and marking it known failed outright — HTTP 500 from the term form, and silently nothing from the status shortcut. Because an import covers essentially the whole language, this touched more or less every word in every text.
The import links what it creates now, and a migration repairs the terms already imported, so an install that has been through an import comes back correct on upgrade rather than needing the import run again. Adding a term that turns out to already exist updates it instead of failing. Imported terms also get the word count they were missing, without which every text added after an import came back unmarked all over again. (#283)
The feed wizard is one page
Four steps, four POSTs, and between them the parsed feed, the fetched article and your picked selectors lived in $_SESSION — the selectors as <li> markup that the next page parsed back out of the previous page's DOM.
The four steps are panels of one page now. The article is fetched once and shared between the steps that need it rather than re-fetched on every hop, and reopening a saved feed is a plain link: /feeds/wizard?edit_feed={id}.
The wizard also stops asking for the language it already knows. The navbar has been asked, and the wizard's own curated and manual paths had always saved under it without asking; the last step, the manager's feed form and the feed list all follow it now. A feed you are editing keeps whichever language it was saved with.
Checking a text no longer costs you your edits
The check page and the editor's Check button both posted a form and got back a whole server-rendered document, so checking from the editor threw away everything else in it. The report is fetched and rendered in place now — neither leaves the page it was on.
The forms became an API
Underneath all of the above, the forms that posted back to their own page are gone. Five endpoints take their place:
POST /api/v1/texts/check report on a parsed text without saving it
POST /api/v1/feeds/wizard/preview read an RSS feed's article list
POST /api/v1/feeds/wizard/article fetch one of those articles
PUT /api/v1/texts/archived/{id} save an archived text
POST /api/v1/local-dictionaries create a local dictionary
This matters past tidiness: the interface works against a configurable API base URL now rather than the page origin, which is what a separate frontend needs.
A failed write no longer reports success
API handlers signal failure by returning an error payload rather than by throwing, and the routers handed that straight to Response::success(). The request came back HTTP 200, and the interface — seeing a success — did nothing at all: no message, no retry. Such a payload is recognised and sent as 400 now.
The client had the matching half of the bug: it only ever looked for message on a failed request, while the API sends error, so even a well-formed error surfaced as a bare "HTTP 400: Bad Request". It reads both. (#284)
A repair that could not repair
addMissingForeignKeys() adds a constraint with a plain ALTER TABLE, and InnoDB validates the existing rows when it does. That contradicts the premise of the repair: an install is missing a constraint precisely because it spent time without one, so the rows the constraint would have prevented are already there. InnoDB answered errno 1452 and refused exactly the keys most worth putting back — and the failure was only logged, so the constraint was lost with nothing saying so.
It worked wherever it was actually reached, because every caller happened to set FOREIGN_KEY_CHECKS = 0 around its own migration run. Nothing established that; the method inherited it from whichever caller was on the stack. It establishes it itself now.
Security
Creating a local dictionary from the index's quick-create form passed the URL's language id straight through, while the API path sitting beside it checked that the language belonged to you. LdLgID carries no foreign key, so on a multi-user install a POST naming another user's language id planted a dictionary row pinned to their language. Create goes through the API now, which removes the unguarded path rather than duplicating the guard. Single-user installs — the default — were never affected.
npm audit also flagged nanoid below 3.3.18 (GHSA-2v37-7h3g-55p8). It reaches the project through the Vite build chain rather than anything LWT serves, so nothing was exposed at runtime, but the build no longer pulls a flagged version.
Dependencies
Refreshed within the ranges already declared — Alpine 3.15 to 3.17, Lucide 1.28 to 1.37 and Vite 8.2.0 to 8.2.2 on the frontend; Guzzle 7.15.2 to 7.15.5 and Symfony Console 7.4.15 to 7.4.17 on the backend. Three overrides the ecosystem has outgrown are dropped, and the three that remain are load-bearing rather than leftovers.
Upgrading is the usual docker compose pull && docker compose up -d --force-recreate. This release carries a migration that repairs dictionary-imported terms, so take a dump first, as always. Full detail in CHANGELOG.md.
LWT 3.5.0: Graded reviews, Chinese word splitting, and schemas that survive
Reviews are graded the way Anki grades them, Chinese texts split into words, and an install whose schema came out incomplete stays usable instead of answering 500. If you run LWT in Docker, read the first section before upgrading — this release changes where the database lives.
Before you upgrade: the database moved to a named volume
docker-compose.yml used to bind-mount the database directory from the host (./lwt_db_data). On Windows that path is case-insensitive, so MariaDB forced lower_case_table_names=2, which its Linux build does not support, and every ALTER TABLE failed its tablespace rename — the cause of the failing clean installs in #275. The database now lives in a Docker named volume instead.
An existing install will look empty after upgrading. Nothing is deleted: your data is still sitting in ./lwt_db_data, it is simply no longer mounted. Dump it before you upgrade, and import it afterwards:
# before upgrading, against the running stack
docker exec lwt_db mariadb-dump -uroot -p"$DB_PASSWORD" --single-transaction \
--databases learning-with-texts > lwt-backup.sql
# after docker compose up -d
docker exec -i lwt_db mariadb -uroot -p"$DB_PASSWORD" < lwt-backup.sqlTake that dump regardless: this release also drops three columns from words, and there is no downgrade path once it has run.
Reviews are graded Again / Hard / Good / Easy
The review card's Wrong/Correct pair becomes the four grades FSRS needs, each showing when it would bring the term back — "3d", "2mo" — before you commit to it. Keys 1-4 grade, as in Anki; the arrow keys still work, and setting a status outright moved to Shift and the number keys.
All three correct grades still raise the term's status by one, exactly as Correct did, so your reading colours are unchanged. What differs is what the scheduler learns from the answer.
The queue follows that schedule now, picking and ordering by each term's due date rather than the Leitner score LWT has carried since the original. A term you have not graded keeps the schedule it already had — it falls due exactly where its status put it — so nothing floods or empties when you upgrade, and it moves onto real scheduling the first time you grade it. Two statuses come up a day earlier than before, where the old formula rounded before comparing.
With that, the legacy scoring is gone: WoTodayScore, WoTomorrowScore and WoRandom were a stored copy of a formula over a term's status and the date it changed, and a migration drops all three. Nothing is lost, because the values were derived rather than entered. The nightly recomputation across your whole vocabulary goes with them, and the vocabulary list's Score column becomes Due, counting days until the term returns.
Anki decks arrive already scheduled
A term LWT has scheduled now exports as a review card rather than a new one. It is due in Anki when it is due in LWT, and carries its interval, review count, lapse count, FSRS memory state and full review history, so Anki's own FSRS continues from LWT's estimate instead of starting the term over. Suspended terms keep their schedule behind the suspension, so unsuspending resumes rather than restarts.
Nothing flows the other way — re-importing a deck still updates only fields and suspension.
Chinese texts can be read
A Chinese language created from the built-in preset produced a text with no clickable words at all: nothing to look up, nothing to track. Three things had stacked up behind that, and the root of it was that the Parser Type setting in the language form was decorative — it was written to the database and no part of the parsing pipeline ever read it. Choosing "Jieba (Chinese)" or "MeCab Python" parsed exactly as before.
The setting is honoured now, and the menu lists the parsers from config/parsers.php, which it never did, so jieba and MeCab Python appear on an install that has them — the published Docker image among them. The Chinese and Japanese presets ask for a real tokenizer accordingly. Where that tokenizer is not installed the language falls back to character-by-character parsing, so the text stays readable either way.
Every language that exists today parses exactly as it did. That is worth stating plainly, because it was nearly not true: an earlier cut of this change re-tokenized every character-split language on upgrade, and a Chinese text went from 103 words to 122 while its saved terms went on matching the old split.
A text that parses into nothing says so
A language whose Word Characters setting does not match the script of its texts does not fail. It parses successfully into nothing — the text saves, opens, shows every character, and refuses to respond to any click, with nothing anywhere to explain why. The reading view and the check-text page now say what happened and link straight to the language's settings.
An incomplete schema stays usable
If a migration failed on your install, the release before this one would have made things worse rather than better. The review queue reads each term's due date from term_schedule, and a table named in SQL cannot be guarded the way a missing row can — MySQL rejects the whole statement at prepare time. So on a schema missing that table, the review page and the entire vocabulary list answered 500, where 3.4.2 had still been usable.
Both now fall back to the schedule a term's status implies, which on such an install is the same answer, since nothing there has been graded yet.
The reason installs were left in that state is fixed too. A failed migration was only reconsidered when an upgrade brought new migration files along — which on a fresh install can never happen, since the first run records every migration and there is never anything new afterwards. A failure stayed failed at one attempt, forever, until some later release happened to add a file. Failures are now retried on the following requests, up to three attempts, and an upgrade restores that budget so a migration that failed on a prerequisite a later one repairs still gets its chance.
Migration logging got the same attention: a healthy fresh install used to write ~178 Migration failed: lines while every one of its migrations succeeded, because legacy migrations rename tables a fresh install never had and those statements fail by design. They log as skipped now, and Migration failed: means it.
Adding a term on a large vocabulary
Opening the term editor read every term of the language into memory to look for similar ones. That is affordable for a vocabulary built by hand and fatal for one seeded from a dictionary import, where clicking a word spent a minute before dying on PHP's memory limit. Candidates are selected in the database now, so the work no longer grows with the size of your vocabulary. Suggestions themselves are unchanged.
Two improvements to what it suggests, while we were there. Similar terms follow word families rather than spelling alone, so bought and buy find each other despite sharing no letters. And for a compound like Geschwindigkeitsbegrenzung, suggestions are picked one at a time with the term shrinking to what is still unexplained after each pick, so the word covering the other half is no longer crowded out by a dozen near-identical siblings.
Upgrading is the usual docker compose pull && docker compose up -d --force-recreate — but read the first section first, and take the dump. Full detail in CHANGELOG.md.
LWT 3.4.2: The RSS feed wizard works again
A repair release for the RSS feed wizard, which has not been able to produce a feed since 3.4.0. If you add feeds by pasting a URL and letting the wizard walk you through picking the article, take this one.
The wizard could not save a feed
Two faults sat in the same flow, and either one alone was enough to stop it.
The last step saved nothing. Step 4 posted the finished feed to /feeds/edit. In 3.4.0 that route became a redirect to the feeds manager, when the duplicated server-rendered feeds list was retired — and a redirect discards the body it was sent. So the walk ran to completion, said nothing was wrong, and left no feed behind.
Steps 2 and 3 showed no article to pick from. Those steps render the fetched page so you can click the part that holds the article. The controller handed the view the extractor's whole result where the article's HTML belongs, so the picker showed the word Array — and the "Array to string conversion" notice behind it is fatal wherever PHP warnings are, which on those installs blocked the wizard at step 2 outright.
Both are fixed, and both were present in 3.4.0 and 3.4.1. The manual add a feed tab and the curated-source browser never went through either path and were unaffected — that is why the wizard's state went unnoticed for two releases.
One thing worth having anyway: the fetched article is cached in the session again, as it was always meant to be. Stepping back and forth between steps 2 and 3 refetched the page from the source every time; it now costs milliseconds.
The language on a new text or feed is checked for ownership
Multi-user installs only. texts.TxLgID and news_feeds.NfLgID carry foreign keys to languages, but a foreign key proves the row exists — not that the caller owns it. The form handlers passed the submitted value straight to the facade, so a crafted request could file a text or feed under another user's language.
The API endpoints these forms now use check ownership, and the form-POST routes that skipped the check are retired. Single-user installs were never affected.
The text editor and the feed forms save through the API
Creating or editing a text now uses POST /api/v1/texts and PUT /api/v1/texts/{id}; the feed forms use POST and PUT /api/v1/feeds. Neither text endpoint existed before — TextsApi.create() had been calling a 404 since it was written.
Nothing changes for you in the browser. It matters for the mobile client: both surfaces now work against a configurable API base URL rather than the page origin, which is what a bundled client needs. Two things stay server-rendered on purpose — the editor's Check button, which asks for a parsing report rather than saving, and the wizard's URL steps, which drive a server-side session.
Upgrading is the usual docker compose pull && docker compose up -d --force-recreate. No migration, no first-boot pause. Full detail in CHANGELOG.md.
LWT 3.4.1: Databases repair their own foreign keys
A database repair release. Worth taking for anyone whose install has been upgraded more than once.
Foreign keys earlier upgrades dropped are put back
Every release before 3.4.0 dropped the foreign keys to make way for its migrations and put back only the ones a pending migration happened to own. The constraints created by migrations that had already run were gone for good, and no later upgrade could recover them: a 3.3.0 database had 14 of the 28 constraints the schema declares.
Nothing looked broken, which is why it went unnoticed for so long. What was actually missing:
- deleting a text left its occurrences and sentences behind
- no orphan protection on
word_tag_map,books,local_dictionaries,news_feedsorwhisper_jobs
3.4.0 stopped the bleeding — an upgrade no longer destroys constraints — but preserving what is there cannot bring back what is already gone. SchemaConstraints::FOREIGN_KEYS now states what the current schema should have, and the upgrade adds whatever is absent, so an affected database mends itself on first boot. No manual SQL.
Rows that a missing constraint would have prevented are already in these databases, so constraints are added with checks disabled: existing data is left exactly as it is, and writes are gated from here on. A constraint the engine still refuses is listed on the admin Server Data page instead of failing the upgrade.
What it costs
Timed on a deliberately large database — 3.05M occurrences, 155k terms, 760 texts, 210 MB:
| First boot after upgrading | 8 to 10 seconds, once |
| Later upgrades on the same database | under a second |
Creating the constraints is not the expensive part; each takes well under a second, because the columns already carry a covering index. The cost is the column realignment introduced in 3.4.0, which rewrites the table — so a table's columns are now realigned in a single ALTER rather than one each. word_occurrences has four of them, and rewriting a three-million-row table four times took 27 seconds where one pass takes under ten.
Expect that to scale roughly with your largest table. Nothing is asked of you either way: the pause happens once, on the first page load after the upgrade.
Upgrading is the usual docker compose pull && docker compose up -d --force-recreate. Full detail in CHANGELOG.md.
LWT 3.4.0: Self-repairing database upgrades, shell-free books
Two database fixes make this release worth taking for anyone upgrading: migrations that fail are no longer written down as successful, and an upgrade no longer strips the database of its foreign keys. Both were found chasing a reported crash where importing an EPUB, editing a language or importing a dictionary all failed with Table 'books' doesn't exist.
Fixed
A migration that failed was recorded as applied, so the schema silently stayed broken (#247, #271). The runner logged each failing statement and wrote the migration down as applied anyway, so it was never retried and the gap only surfaced much later as a 500 in whatever feature needed the missing table.
That is how installs ended up without books and local_dictionaries. Both declare a foreign key on languages(LgID) typed INT(11) UNSIGNED, and where languages.LgID was still tinyint(3) — the widening in 20251221_120000_add_inter_table_foreign_keys.sql having never taken effect — InnoDB refused the whole CREATE TABLE with errno 150, even under FOREIGN_KEY_CHECKS = 0.
The runner now records the real outcome (status, attempts and error on _migrations) and retries a failure when a later upgrade may have fixed its prerequisite. Reference columns are realigned with the key they point at, on the widest member of each family, so a column is only ever widened and no value can be truncated. A repair migration recreates the tables and columns the failed migrations never created, so an affected database mends itself on first boot, with no manual SQL. Anything still failing is listed on the admin Server Data page instead of only in error_log.
Upgrading dropped every foreign key and only put some back (#272). Migrations run with the constraints cleared, because ALTER TABLE MODIFY is refused on a column one points at, but only pending migrations recreate theirs — and the migrations that created the rest were applied long ago. A 3.3.0 database upgraded to this release went from 14 constraints to none: nothing looked broken, but cascade deletes and orphan protection were gone. The set is now captured before the drop and restored afterwards.
DELETE /api/v1/books/{id} and PUT /api/v1/books/{id}/progress returned 405 despite being fully implemented, because the endpoint registry is keyed by path and a URL carrying an ID matches no key exactly. Both routes now work.
Added
Books are shell-free. /books and /book/{id} render entirely from /api/v1. The list keeps its behaviour: the language filter and pagination update the address bar, so a bookmarked ?lg_id=&page= still opens where the reader left off.
Security — XSS hardening, phase 8
The "build markup by string concatenation" pattern is gone from the frontend, closing DOM XSS holes where Google Translate output, multi-word markers, language names and LibreTranslate connection errors reached the DOM as markup. Regression is now held by an invariant and hostile-input tests rather than by vigilance.
Changed
EPUB import no longer depends on an external Composer package (#263). LWT ships its own EPUB reader, replacing kiwilan/php-ebook and four transitive dependencies, taking composer.lock from 20 packages to 15. XML is parsed with entity substitution off and network access disabled, so a hostile EPUB cannot mount an XXE attack.
Removed
Seven orphaned view templates, the last of the frame-era result plumbing, two superseded legacy term routes and the legacy multi-word edit page.
Upgrading is the usual docker compose pull && docker compose up -d --force-recreate; the database migrates itself on first boot. The full changelog is in CHANGELOG.md.
LWT 3.3.0: Anki deck import, .apkg round-trip, and FSRS groundwork
Anki interoperability lands in this release, alongside the groundwork for FSRS scheduling and a reader fix that anyone selecting multiple words has been running into.
Added
Import an Anki deck to seed known words (#228): point LWT at a deck you already study in Anki and it creates the terms for you, working out how well you know each word from Anki's own scheduling. Mature cards become well known, younger ones get a learning status, and suspended ones become ignored. You pick the note type, which field holds the term, and the language, since an .apkg records none of that. Importing is create-only, so running it twice is safe. This is the direction #259 did not cover: that one round-trips LWT's own exports and silently matches nothing in a deck built in Anki. See docs/reference/anki-deck-import.
Anki .apkg export and import (refs #228): terms round-trip to Anki as a real .apkg, written and read by LWT itself, with no genanki and no Python. Export a whole language or just the rows you ticked, from the vocabulary list. Notes carry a stable guid, so re-importing updates the terms they came from (translation, romanization, notes, tags), and a card suspended in Anki demotes a learning term to Ignored. Scheduling state is deliberately not exchanged. See docs/reference/anki-export-import.
FSRS scheduling groundwork (#238, phase 2a): LWT now records FSRS-6 memory state per term, meaning stability, difficulty, due date and a review history. Nothing user-visible changes yet: the legacy scoring still drives the review queue and reading colours are untouched. Existing terms seed lazily from their status, so upgrading neither floods the queue nor costs anything on a large vocabulary. See docs/developer/term-status-fsrs.
Fixed
Selecting several words in the reader produced a term named after hashes. Creating a multi-word term captured each word's data_hex identity token instead of its text, so the term came out as e.g. "e6967a5826fe441a 75e3090289e2955d", and the example sentence lost its {...} markers. data_hex used to be a reversible hex encoding of the word, which is why reading it once worked; it became a SHA-256 derived token in 3.2.0 (#237) and this call site was never updated.
The "edit term" button in the review table went nowhere (#266): it pointed at edit_tword.php, a filename that stopped being routed in v3, and opened it in a frame the reader no longer renders. It now links to /word/edit-term, the same route the Alpine review view already used.
Removed
Dead legacy result-view plumbing (#266): four result handlers (delete_result, insert_wellknown_result, insert_ignore_result, delete_multi_result) outlived the views that once fed them, and word_status_ajax.ts waited on a #word-status-config element no page has emitted since the frame reader was retired. Dropping them plus their now orphaned DOM helpers removes around 1000 lines. No behaviour change, since none of it could run.
Note on the 3.2.2 changelog
The 3.2.2-fork changelog section was missing from CHANGELOG.md and has been restored in this release. It was written by the 3.2.2 release commit and then removed by a merge whose branch predated the release, which folded everything 3.2.2 shipped back under [Unreleased]. If you are on 3.2.2 and found no changelog for your version, it is there now.
Full changelog: https://github.com/HugoFara/lwt/blob/main/CHANGELOG.md
LWT 3.2.2: Reading-flow fixes, language resolution, and security patches
Patch release bundling the fixes landed since 3.2.1 (2026-06-30).
Reading and import
- The term modal no longer hides what you are reading (#253). Clicking Add/Edit used to open a full-viewport modal over the text. The backdrop dimming is gone, the mobile card is a capped-height sheet, and the modal now places itself in the half of the viewport the clicked word is not in — so the word and its sentence stay visible while you type a translation.
- A single-language install could never browse Gutenberg or GDL.
currentlanguageis only written when you pick from the navbar dropdown, and with exactly one language there is no second option to switch to, so the setting could never be written — a fresh install was stuck there permanently. The navbar looked correct only because a<select>with no selected option displays the first one. Language resolution is now centralised, and a setting pointing at a deleted language no longer sticks either. - A CRLF term file broke tag-only imports. The last remaining site of the
PHP_EOLsplitting defect fixed in #241, #248 and #249. - One over-long headword aborted a whole dictionary import (#250). Importing FreeDict German-English lost all 517,533 usable entries because of a single 293-character headword. Unstorable headwords are now skipped and counted.
- Restoring a backup wiped the database and reported success on Windows (#249), plus foreign-key ordering during replay and a CSP bug that made the restore file-picker a no-op.
- Japanese (MeCab) parsing produced no tokens on Windows.
Internal
TermStatusis now the single source of truth for the word-status model (#246), replacing scattered status literals across PHP and TypeScript.- Single
data_hexword identity in the reading view (#237), retiring the 2011¤/hex encoder that PHP 8.5 had begun deprecating.
Neither changes behaviour or wire format.
Security
Thirteen advisories cleared across two rounds — ten in #255, three more in #257. The latter includes two guzzle CVEs that reach the running application through league/oauth2-google (CVE-2026-69246, high; CVE-2026-69245, medium) and a brace-expansion override floor that its own follow-up advisory had overtaken.
composer audit and npm audit both report clean.
Upgrading
No migration or configuration change is required. Full detail in CHANGELOG.md.
LWT 3.2.1: First-boot database crash & reverse-proxy mixed-content fixes
Patch release fixing two issues that surfaced since 3.2.0.
Fixed
- Database error on first boot with CRLF schema files (#241). A fresh install could fail to start with "Internal Server Error - A database error occurred." (MySQL error 1064) while applying
db/schema/baseline.sql.SqlFileParsersplit statements on';' . PHP_EOL— which is";\n"on a Linux host — so a schema file with Windows (CRLF) line endings was never split, and the whole file was sent to MySQL as a single multi-statement query that it rejected. The parser now normalizes CRLF/CR to LF before splitting, so parsing no longer depends on the file's origin OS. - Mixed-content asset blocking behind a TLS-terminating reverse proxy (#240). The Content-Security-Policy now emits
upgrade-insecure-requestson HTTPS connections, so any strayhttp://subresource is transparently upgraded tohttps://instead of being blocked as mixed content — this is what left the language picker (and other scripts) unloaded on installs whose proxy doesn't propagate the scheme. The directive is omitted over plain HTTP so local development isn't forced to upgrade.
Full Changelog: 3.2.0...3.2.1
LWT 3.2.0: Shell-free mobile client, security hardening, and the Kids' Library
A feature release built around two themes: making LWT a real backend for a shell-free mobile client, and a broad, methodical security-hardening pass across the auth, upload, and fetch surfaces. Plus a new source of easy reading material, the Global Digital Library.
Highlights
Shell-free / mobile client
The web shell is no longer required to render the app. The global navbar, the reader chrome (book navigation + audio player), and UI translations are now served from the REST API and rendered client-side, and the review surface and feedback sounds are bundled. A packaged client can choose its server, register and log in in-app, and keep its session alive with proactive token refresh (with a clean 401 teardown). This is what the Lukaisu Android client connects to.
New text source: the Global Digital Library ("Kids' Library")
Browse and search openly-licensed (CC-BY / CC-BY-SA) children's and early-grade readers — including StoryWeaver content — straight from the New Text page, filling the gap in easy texts that Gutenberg and Wikisource leave. Books import via ePUB extraction, image-only picture books are rejected, and difficulty tiers come from GDL's reading levels. The home page shows beginner-aware GDL suggestions: low-vocabulary readers see the easy books first, advanced readers see them below the classics.
Registration without email + recovery code + captcha
The username is now the unique identity, so sign-up needs only a username + password. Email becomes an optional recovery channel. Email-less accounts get a one-time recovery code (shown once, with a /password/recover reset flow that rotates on use). Registration is protected by a self-hosted ALTCHA proof-of-work captcha (no third-party service, no user puzzle; ALTCHA_ENABLED / ALTCHA_HMAC_KEY), plus a honeypot and submission-timing check.
StarDict dictionary uploads via archives (#233)
The import form now accepts .zip, .tar.gz, .tar.bz2, .tar.xz, and .tgz containing the StarDict triplet, and FreeDict downloads import directly. Extraction is shared via a new ArchiveExtractor (zip-bomb cap, path-traversal guard, automatic cleanup).
Security hardening
A multi-phase audit closed a wide range of issues, each with regression tests:
- XSS: fixed
json_encode-into-<script>breakouts (missingJSON_HEX_TAG | JSON_HEX_AMP), DOM sinks in the word popup / tooltips / Glosbe translations, and theaddslashes-into-attribute anti-pattern (feed browse, confirm dialogs). - CSRF: added real CSRF enforcement to the auth POST endpoints (
/login,/register,/password/*, email re-verification) and fixed bulk vocabulary / texts actions that posted without a token. - Auth: open-redirect fix on
auth_redirect, timing-safe OAuth state comparison, and invalidation of remember-me + API tokens on password change/reset. - Authz / IDOR: cross-table ownership guards on dictionaries, feeds, and sentence lookups (
languageBelongsToCurrentUser). - SSRF: outbound fetches (RSS, web/article extractors, Gutenberg, Wiktionary) now route through a central
safeHttpGetthat disables stream-level redirects and re-validates every hop. - Uploads: defensive depth for importers — filename sanitization at the boundary, tar list-before-extract with file caps, size caps on subtitle/JSON/CSV imports, BOM/UTF-16 handling, and reliable temp-file cleanup.
- Audio: hardened position save (
pagehide+sendBeacon, periodic checkpoint), float precision, Whisper MIME re-validation, and a rate limit on transcription. - Dependency scans (
composer audit,npm audit --omit=dev) report 0 advisories.
Fixed
- Navbar hamburger hidden under the status/camera bar on edge-to-edge phones — the navbar now respects safe-area insets.
- Login/registration field icons rendering outside the input (PurgeCSS stripped Bulma's
.icon.is-left/right; now safelisted). - Misspelled
currentlangagesettings key broke the current-language TTS voice and term-translation language context. - 429 PHP 8.5 deprecation warnings cleared (redundant
setAccessible(true)in tests;ord()on a multi-byte char). - Saving a text with multi-word expressions in multi-user mode 500'd on a binding-misalignment FK violation.
- Saving 2+ tags on a term/text threw on the 20-char cap (Tagify comma-serialization now split in the service layer).
- Multi-word term selection captured inline translation hints (now reads the clean surface form).
Developer proposals (docs only)
- Single
data_hexword identity (#237) — replace theTERM<hex>class-as-index with adata_hexattribute. - Term-status model + FSRS scheduling (#238) — collapse the scattered 1–5/98/99 literals onto
TermStatusand align review scheduling with Anki/FSRS.
Both are proposals; implementation is deferred.
Full changelog: https://github.com/HugoFara/lwt/blob/main/CHANGELOG.md
3.1.1: EPUB import fixes, minor UI fix
A patch release focused on UX rough edges introduced after 3.1.0. EPUB import is now consolidated into the New Text page, the navbar theme toggle actually works again, and the navigation behaviour after saving any setting is no longer hidden behind stale-cache HTML.
Highlights
EPUB import consolidated onto /texts/new
Picking an .epub under Source → File → From computer now imports inline as a book. The standalone /book/import page still works for legacy bookmarks but shows a deprecation banner. Action-card buttons that pointed there now go to /texts/new. The receiving handler accepts both the legacy field names (LgID, thefile) and the new-text form's names (TxLgID, importFile), so existing direct POSTs keep working.
Tabbed file source
From computer and From server are now tabs instead of stacked sections, so the unused option is hidden by default.
Bug fixes
- EPUB upload (#232): the original filename is now forwarded to the parser. PHP upload temp paths (
/tmp/phpXXXXXX) carry no extension, which used to makekiwilan/php-ebookbail with "File has no extension". - Tagify chunk crash on
/texts/new: Vite 8 / Rolldown was emitting a brokentagify_exportsnamed export when the CSS was dynamically imported. Switched to a static CSS import. setTheFocus()crash: the EPUB form's language<select class="setfocus">was triggeringTypeError: e.select is not a function. The helper now only calls.select()on<input>/<textarea>.- Spurious "1 field(s) must not be empty" alert during EPUB import: the
.notemptyvalidator now skips fields hidden via Alpinex-showor thehiddenattribute. - Theme / language change appearing only after navigation: the service worker used stale-while-revalidate for HTML, so a setting save + reload silently served the previous render. Navigation requests now use network-first; static assets stay cache-first. Service-worker
CACHE_VERSIONbumped tov2, so the old cache is dropped on activate. Existing tabs pick up the change on next load. - Navbar theme toggle click ignored under CSP-build Alpine:
@alpinejs/cspdoes not bind@clickreliably on the same element asx-data. The listener is now attached imperatively in the component'sinit(). Cypress regression test added. - Theme save with no visible change: when in auto mode and the OS preference matches the would-be light/dark target, the saved value is a no-op. The toggle now shows a confirmation toast after reload, and the toast explains the auto-mode case.