fix: a reload keeps the document protected, and the first save asks for the password

The password itself is never stored, so a reload left the document open and
the next save wrote the protected file unencrypted without a word. The fact
of the protection now survives; the save dialog asks for the password again
or lets the protection go, and backing out cancels the save.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:12 +02:00
1 parent 87743fdd57
commit cb0db7dd96
7 files changed
+65 -7

No files matched your search

+4 -1
View File
@@ -19,7 +19,10 @@ Encryption is the **last** thing to touch the bytes: `writeHandle`/`download` in
.odt into an .ott. Decryption is the **first**: the top of `applyImport` in `App.svelte`,
before `convertUnsupportedImages`, which would otherwise unzip an encrypted archive and find
nothing. The password lives in `docPassword` for the session only — the autosaved copy in
localStorage stays plain text, as both reference word processors' recovery copies do.
localStorage stays plain text, as both reference word processors' recovery copies do. What
survives a reload is the fact of the protection (`edentext-doc-protected`): the first save
then asks for the password again, or for the protection to be lifted, so a reload never
turns a protected file into an open one.
## ODF
+41 -5
View File
@@ -38,7 +38,7 @@
import { DEFAULT_NOTE_SETTINGS } from './lib/storage/noteSettings';
import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } from './lib/storage/headerFooter';
import { loadDocName, saveDocName, loadDocFormat, saveDocFormat, stripOdtExtension, sanitizeNameForFile, deriveFilename, filenameFor, type DocumentFormat } from './lib/storage/documentName';
import { loadDocName, saveDocName, loadDocFormat, saveDocFormat, stripOdtExtension, sanitizeNameForFile, deriveFilename, filenameFor, loadDocProtected, saveDocProtected, type DocumentFormat } from './lib/storage/documentName';
import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties';
import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation';
import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering';
@@ -340,6 +340,7 @@
$effect(() => {
saveDocName(documentName);
saveDocFormat(documentFormat);
saveDocProtected(docProtected);
});
function setZoom(value: number) {
@@ -497,7 +498,11 @@
// The password the document is saved with. Session-only: it is never written to
// localStorage, and the autosaved copy there stays unencrypted.
let docPassword: string | null = $state(null);
// Set after a reload: the document is protected, but the password went with the session.
let passwordLost = $state(loadDocProtected());
let docProtected = $derived(docPassword !== null || passwordLost);
let passwordSetOpen = $state(false);
let setResolve: ((decided: boolean) => void) | null = null;
let passwordAskOpen = $state(false);
let passwordWrong = $state(false);
let askResolve: ((password: string | null) => void) | null = null;
@@ -603,6 +608,7 @@
fileHandle = null;
documentFormat = 'odt';
docPassword = null;
passwordLost = false;
// Styles and note settings live in the document, so a new one starts from the built-ins
setStyleSheet(builtinStyleSheet());
setNoteSettings(DEFAULT_NOTE_SETTINGS);
@@ -662,6 +668,29 @@
resolve?.(password);
}
function applyPassword(password: string | null): void {
docPassword = password;
passwordLost = false;
setResolve?.(true);
setResolve = null;
}
function cancelPasswordSet(): void {
setResolve?.(false);
setResolve = null;
}
// A reload keeps the document marked protected but not its password: the first save
// asks for it again, or for the protection to be lifted, rather than writing the
// file open. False = the user backed out, and the save with it.
function ensurePassword(): Promise<boolean> {
if (!passwordLost) return Promise.resolve(true);
return new Promise((resolve) => {
setResolve = resolve;
passwordSetOpen = true;
});
}
// Ask until the password fits or the user gives up; null means give up.
async function unprotect(bytes: Uint8Array): Promise<{ bytes: Uint8Array; password: string } | null> {
for (;;) {
@@ -774,6 +803,7 @@
// The file's password belongs to the document once it is actually the open one,
// and an unprotected file drops the previous document's.
docPassword = password;
passwordLost = false;
if (sourceName) recentFiles = await rememberRecentFile(sourceName, isTemplate ? null : handle);
// Warn about fonts the document uses but the browser can't render, so text
@@ -874,6 +904,7 @@
async function handleSave() {
if (!editor) return;
exportMenuOpen = false;
if (!(await ensurePassword())) return;
const json = editor.getJSON() as TiptapNode;
try {
// A document opened as .docx round-trips through the same format, like both
@@ -903,6 +934,7 @@
async function handleSaveAs() {
if (!editor) return;
exportMenuOpen = false;
if (!(await ensurePassword())) return;
const json = editor.getJSON() as TiptapNode;
try {
const { buildOdt } = await import('./lib/export/odt');
@@ -923,6 +955,7 @@
async function handleSaveTemplate() {
if (!editor) return;
exportMenuOpen = false;
if (!(await ensurePassword())) return;
const json = editor.getJSON() as TiptapNode;
try {
await saveAsTemplate(async (kind) => {
@@ -976,6 +1009,7 @@
async function handleSaveDocx() {
if (!editor || docxBusy) return;
exportMenuOpen = false;
if (!(await ensurePassword())) return;
docxBusy = true;
try {
const json = editor.getJSON() as TiptapNode;
@@ -1248,7 +1282,7 @@
onAbout={() => (aboutOpen = true)}
onDocProperties={() => (docPropsOpen = true)}
onProtect={() => (passwordSetOpen = true)}
hasPassword={docPassword !== null}
hasPassword={docProtected}
onAutoCorrect={() => (autoCorrectOpen = true)}
onAutoText={() => (autoTextOpen = true)}
onNewComment={addComment}
@@ -1376,7 +1410,7 @@
{/if}
<button class="theme-option" onclick={() => { exportMenuOpen = false; passwordSetOpen = true; }} role="menuitem">
<span>{t().password.menu}</span>
{#if docPassword !== null}<span class="theme-option-hint">{t().password.menuOn}</span>{/if}
{#if docProtected}<span class="theme-option-hint">{t().password.menuOn}</span>{/if}
</button>
<div class="theme-heading">{t().docProps.title}</div>
<button class="theme-option" onclick={() => { exportMenuOpen = false; docPropsOpen = true; }} role="menuitem">
@@ -1666,8 +1700,10 @@
<PasswordDialog
bind:open={passwordSetOpen}
mode="set"
hasPassword={docPassword !== null}
onApply={(password) => (docPassword = password)}
hasPassword={docProtected}
lost={passwordLost}
onApply={applyPassword}
onCancel={cancelPasswordSet}
/>
<PasswordDialog
bind:open={passwordAskOpen}
+4 -1
View File
@@ -3,11 +3,13 @@
// Two jobs, one dialog: setting the password a document is saved with, and asking
// for the one an encrypted document was saved with.
let { open = $bindable(false), mode, hasPassword = false, wrong = false, onApply, onCancel }: {
let { open = $bindable(false), mode, hasPassword = false, wrong = false, lost = false, onApply, onCancel }: {
open?: boolean;
mode: 'set' | 'ask';
hasPassword?: boolean;
wrong?: boolean;
// The document is protected but the session no longer has the password (a reload).
lost?: boolean;
onApply: (password: string | null) => void;
onCancel?: () => void;
} = $props();
@@ -74,6 +76,7 @@
<h2>{mode === 'set' ? t().password.setTitle : t().password.askTitle}</h2>
{#if wrong}<p class="wrong">{t().password.wrong}</p>{/if}
{#if lost}<p class="note">{t().password.lost}</p>{/if}
<div class="row">
<label>
+1
View File
@@ -1123,6 +1123,7 @@ const de: Messages = {
show: 'Passwort anzeigen',
hide: 'Passwort verbergen',
remove: 'Schutz entfernen',
lost: 'Das Passwort bleibt über einen Reload nicht erhalten. Gib es für die Datei erneut ein, oder entferne den Schutz.',
},
importError: {
odtArchive: 'Keine gültige .odt-Datei (Archiv konnte nicht gelesen werden).',
+1
View File
@@ -1131,6 +1131,7 @@ const en = {
show: 'Show password',
hide: 'Hide password',
remove: 'Remove protection',
lost: 'The password is not kept across a reload. Enter it again for the file, or remove the protection.',
},
importError: {
odtArchive: 'Not a valid .odt file (could not read the archive).',
+1
View File
@@ -40,6 +40,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
- **Document protected:** `edentext-doc-protected` — `'1'` while the open document is password-protected; the password itself is never stored, so after a reload the first save asks for it again or lets the protection go (`documentName.ts`).
- **Document format:** `edentext-doc-format` — `'docx'` while the open document round-trips as .docx; absent = .odt (`documentName.ts`).
- **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string.
+13
View File
@@ -49,3 +49,16 @@ export function saveDocFormat(format: DocumentFormat): void {
if (format === 'docx') localStorage.setItem(FORMAT_KEY, format);
else localStorage.removeItem(FORMAT_KEY);
}
// Whether the open document is password-protected. The password itself is never
// stored, so after a reload this is what makes the first save ask for it again.
const PROTECTED_KEY = 'edentext-doc-protected';
export function loadDocProtected(): boolean {
return localStorage.getItem(PROTECTED_KEY) === '1';
}
export function saveDocProtected(on: boolean): void {
if (on) localStorage.setItem(PROTECTED_KEY, '1');
else localStorage.removeItem(PROTECTED_KEY);
}