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:
1 parent
87743fdd57
commit
cb0db7dd96
7 files changed
+65
-7
No files matched your search
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user