mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.115.9: encrypt any folder, settings polish, follow-up fixes
This commit is contained in:
parent
60b7634519
commit
c01a938b4a
8 changed files with 407 additions and 97 deletions
168
main.js
168
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.115.6",
|
||||
"version": "0.115.9",
|
||||
"minAppVersion": "1.13.0",
|
||||
"description": "A chat-style, nested-notes workspace: rapid capture, outliner navigation, fast search, tasks, and per-folder templates, with one-click Open Knowledge Format (OKF) export for LLMs and agents.",
|
||||
"author": "Human",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.115.6",
|
||||
"version": "0.115.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
43
release-notes/0.115.9.md
Normal file
43
release-notes/0.115.9.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# 0.115.9 — Encrypt any folder, settings polish, and follow-up fixes
|
||||
|
||||
Extends encryption to arbitrary (non-Stashpad) folders, tidies the encryption
|
||||
settings page, and fixes the rough edges found while reviewing the new feature.
|
||||
|
||||
## Encrypt arbitrary folders (0.115.8)
|
||||
- **"Encrypt with Stashpad" now works on any folder, not just Stashpad folders.**
|
||||
Right-click a folder in the file explorer:
|
||||
- A **Stashpad folder** gets its own password and its notes are locked individually
|
||||
(unchanged behavior).
|
||||
- A **non-Stashpad folder** (arbitrary files of any type) is bundled into a single
|
||||
encrypted file: every file is zipped, encrypted under a folder password, and the
|
||||
originals are deleted. A confirmation dialog explains the model before anything
|
||||
happens.
|
||||
- **"Decrypt with Stashpad"** appears on a folder that holds such a bundle and restores
|
||||
every file (including subfolders) back into place, then removes the encrypted bundle.
|
||||
- The bundle is encrypted with AES-256-GCM, the encrypted copy is verified before any
|
||||
original is deleted, and files that change mid-encrypt are left in place and reported
|
||||
rather than lost. Extraction is path-sanitized and never overwrites existing files.
|
||||
|
||||
## Encryption settings polish (0.115.7)
|
||||
- **A single caution note** now sits at the top of the Folders section (covering both
|
||||
archive and trash behavior) instead of being repeated; the folder-panel warning modal
|
||||
is kept for when you encrypt from the folder panel.
|
||||
- **Section headers and dividers** added across the Encryption tab (Folders, global
|
||||
settings, etc.), matching the existing "Sharing" section styling, so the page is
|
||||
easier to scan.
|
||||
|
||||
## Follow-up fixes (0.115.9)
|
||||
- **"Decrypt with Stashpad" no longer appears on regular encrypted Stashpad folders.**
|
||||
It now shows only on folders that actually hold an encrypted bundle, not on folders
|
||||
whose encrypted files are locked notes.
|
||||
- **Re-encrypting a previously-bundled folder is handled correctly.** Encrypting a
|
||||
folder that was bundled and then decrypted re-bundles it as expected instead of doing
|
||||
nothing; a folder that still holds a bundle now tells you to use Decrypt instead.
|
||||
- **Clearer confirmation.** The bundling dialog now states that the action can't be
|
||||
undone with Cmd+Z (the originals are permanently deleted so no readable copy is left
|
||||
in the trash) and that the only way back is to decrypt with the password.
|
||||
- **New command: "Decrypt a folder bundle…"** lists every encrypted folder bundle in
|
||||
the vault and decrypts the one you pick — so bundles are reachable from the command
|
||||
palette, not only the right-click menu.
|
||||
- **Emptied subfolders are cleaned up** after a folder is bundled, instead of being left
|
||||
behind as empty directories.
|
||||
|
|
@ -268,7 +268,7 @@ export interface DeletedMeta {
|
|||
/** "deleted" = a Stashpad note bundle (importStashZip on restore).
|
||||
* "rawtrash" = a raw zip of Obsidian's `.trash/` tree from the backfill
|
||||
* command (plain unzip back into `.trash/` on restore). */
|
||||
v: number; kind: "deleted" | "rawtrash";
|
||||
v: number; kind: "deleted" | "rawtrash" | "rawfolder";
|
||||
/** Folder the note was deleted FROM — where Restore puts it back. EMPTY when
|
||||
* titles are hidden (the sidecar is plaintext; the origin would leak where a
|
||||
* hidden note came from) — `originalFolderEnc` carries it instead. */
|
||||
|
|
@ -604,6 +604,110 @@ export async function listRotTemps(app: App, folder: string): Promise<string[]>
|
|||
.map((f) => f.replace(/\.rot$/, ""));
|
||||
}
|
||||
|
||||
// ---- Generic (non-Stashpad) folder encryption: bundle a whole folder ----
|
||||
|
||||
/** Encrypt an ARBITRARY folder (any file types, not Stashpad notes) into a single
|
||||
* `.stashenc` bundle living inside the folder, then delete the originals. Reuses the
|
||||
* raw-zip path (like the trash backfill). `keyId` is the folder key it's encrypted
|
||||
* under (stamped in the sidecar so decrypt resolves the right key). */
|
||||
export async function lockRawFolder(app: App, folder: string, dek: Uint8Array, keyId: string | undefined, stamp: string): Promise<{ blobPath: string; fileCount: number; unpurged: string[] }> {
|
||||
const cleaned = folder.replace(/\/+$/, "");
|
||||
const files = (await listFilesRecursive(app, cleaned)).filter((f) => !f.endsWith(`.${STASHENC_EXT}`) && !f.endsWith(`.${STASHMETA_EXT}`));
|
||||
if (files.length === 0) throw new Error("This folder has no files to encrypt.");
|
||||
const entries: { name: string; data: ArrayBuffer }[] = [];
|
||||
const mtimes = new Map<string, number>();
|
||||
for (const p of files) {
|
||||
entries.push({ name: `files/${p.slice(cleaned.length + 1)}`, data: await app.vault.adapter.readBinary(p) });
|
||||
try { const st = await app.vault.adapter.stat(p); if (st) mtimes.set(p, st.mtime); } catch { /* no baseline */ }
|
||||
}
|
||||
const zipBytes = await zipFiles(entries);
|
||||
const blob = await encryptWithKey(zipBytes, dek);
|
||||
const back = await decryptWithKey(blob, dek);
|
||||
if (back.length !== zipBytes.length) throw new Error("Encryption self-check failed (size).");
|
||||
for (let i = 0; i < zipBytes.length; i++) if (back[i] !== zipBytes[i]) throw new Error("Encryption self-check failed (content).");
|
||||
const base = safeBlobBase(cleaned.split("/").pop() || "folder");
|
||||
let blobPath = `${cleaned}/${base}.${STASHENC_EXT}`;
|
||||
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${cleaned}/${base} (${n}).${STASHENC_EXT}`;
|
||||
await writeBlobVerified(app, blobPath, blob);
|
||||
const meta: DeletedMeta = { v: 1, kind: "rawfolder", originalFolder: cleaned, parentId: null, title: cleaned.split("/").pop() || "", count: files.length, created: stamp, rootId: "", deletedAt: stamp, ...(keyId ? { keyId } : {}) };
|
||||
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); } catch (e) { console.warn("[Stashpad] couldn't write rawfolder sidecar", e); }
|
||||
const unpurged: string[] = [];
|
||||
for (const p of files) {
|
||||
const baseline = mtimes.get(p);
|
||||
try {
|
||||
if (baseline != null) { const st = await app.vault.adapter.stat(p); if (st && st.mtime !== baseline) { unpurged.push(p); continue; } }
|
||||
await app.vault.adapter.remove(p);
|
||||
} catch (e) { console.warn("[Stashpad] couldn't delete file", p, e); unpurged.push(p); }
|
||||
}
|
||||
// Gap 5: prune subdirectories emptied by the deletion (deepest first) so the folder
|
||||
// isn't left full of hollow dirs. Never touch `cleaned` itself (it holds the blob).
|
||||
if (unpurged.length === 0) {
|
||||
const subdirs = [...new Set(files.map((p) => p.slice(0, p.lastIndexOf("/"))))]
|
||||
.filter((d) => d.length > cleaned.length)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const d of subdirs) {
|
||||
// rmdir's 2nd arg must be `true` even for an empty dir — with `false` the adapter
|
||||
// calls file-`rm` and fails EISDIR. We've already confirmed it's empty, so recursing
|
||||
// removes nothing extra.
|
||||
try { const l = await app.vault.adapter.list(d); if (!l.files.length && !l.folders.length) await app.vault.adapter.rmdir(d, true); } catch { /* keep non-empty/locked dirs */ }
|
||||
}
|
||||
}
|
||||
return { blobPath, fileCount: files.length, unpurged };
|
||||
}
|
||||
|
||||
/** Decrypt a kind:"rawfolder" bundle back into its folder, then remove the blob +
|
||||
* sidecar. Existing files are never overwritten (suffix). */
|
||||
export async function unlockRawFolder(app: App, blobPath: string, dek: Uint8Array): Promise<{ filesWritten: number; folder: string }> {
|
||||
const blob = new Uint8Array(await app.vault.adapter.readBinary(blobPath));
|
||||
if (!isEncryptedStash(blob)) throw new Error("Not an encrypted bundle.");
|
||||
const meta = await readDeletedMeta(app, blobPath);
|
||||
const folder = safeVaultFolder(meta?.originalFolder) ?? blobPath.replace(/\/[^/]*$/, "");
|
||||
const zipBytes = await decryptWithKey(blob, dek);
|
||||
const zip = await unzipFiles(zipBytes);
|
||||
let written = 0;
|
||||
for (const [name, entry] of Object.entries(zip)) {
|
||||
if (!name.startsWith("files/")) continue;
|
||||
const rel = safeTrashRelPath(name.slice("files/".length));
|
||||
if (!rel) { console.warn("[Stashpad] skipped unsafe folder entry", name); continue; }
|
||||
const dir = `${folder}/${rel}`.split("/").slice(0, -1).join("/");
|
||||
let cur = "";
|
||||
for (const seg of dir.split("/")) { cur = cur ? `${cur}/${seg}` : seg; if (cur && !(await app.vault.adapter.exists(cur))) await app.vault.adapter.mkdir(cur); }
|
||||
let dest = `${folder}/${rel}`;
|
||||
for (let n = 1; await app.vault.adapter.exists(dest); n++) dest = `${folder}/${rel.replace(/(\.[^./]*)?$/, ` (${n})$1`)}`;
|
||||
await app.vault.adapter.writeBinary(dest, entry.buffer as ArrayBuffer);
|
||||
written++;
|
||||
}
|
||||
await app.vault.adapter.remove(blobPath);
|
||||
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* */ }
|
||||
return { filesWritten: written, folder };
|
||||
}
|
||||
|
||||
/** The kind:"rawfolder" blob in a folder (the bundle from lockRawFolder), or null. */
|
||||
export async function rawFolderBlobIn(app: App, folder: string): Promise<string | null> {
|
||||
const cleaned = folder.replace(/\/+$/, "");
|
||||
try {
|
||||
const listing = await app.vault.adapter.list(cleaned);
|
||||
for (const f of listing.files) {
|
||||
if (!f.endsWith(`.${STASHENC_EXT}`)) continue;
|
||||
const m = await readDeletedMeta(app, f);
|
||||
if (m?.kind === "rawfolder") return f;
|
||||
}
|
||||
} catch { /* no folder */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every kind:"rawfolder" bundle in the vault, as {folder, blobPath}. Scans the
|
||||
* `.stashenc` files Obsidian has indexed and keeps those whose sidecar is rawfolder.
|
||||
* Used by the command-palette "Decrypt a folder bundle…" picker (gap 4). */
|
||||
export async function listRawFolderBlobs(app: App): Promise<{ folder: string; blobPath: string }[]> {
|
||||
const out: { folder: string; blobPath: string }[] = [];
|
||||
for (const f of app.vault.getFiles()) {
|
||||
if (f.extension !== STASHENC_EXT) continue;
|
||||
try { const m = await readDeletedMeta(app, f.path); if (m?.kind === "rawfolder") out.push({ folder: f.parent?.path?.replace(/\/+$/, "") ?? "", blobPath: f.path }); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** All encrypted-deleted blob paths in `_deleted/`. */
|
||||
export async function listDeletedBlobs(app: App): Promise<string[]> {
|
||||
if (!(await app.vault.adapter.exists(DELETED_DIR))) return [];
|
||||
|
|
|
|||
166
src/main.ts
166
src/main.ts
|
|
@ -10,8 +10,8 @@ import { STASHPAD_TRASH_VIEW_TYPE, STASHPAD_AGGREGATE_VIEW_TYPE } from "./types"
|
|||
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
|
||||
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
|
||||
import { EncryptionService, defaultEncryptionConfig } from "./encryption-service";
|
||||
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree, writeRotatedBlob, commitRotatedBlob, cleanupRotTemps, listRotTemps, deletedBlobsForKeyId, rewrapDeletedSidecar } from "./encryption-ops";
|
||||
import { EncryptionPasswordModal } from "./modals";
|
||||
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree, writeRotatedBlob, commitRotatedBlob, cleanupRotTemps, listRotTemps, deletedBlobsForKeyId, rewrapDeletedSidecar, lockRawFolder, unlockRawFolder, rawFolderBlobIn, listRawFolderBlobs } from "./encryption-ops";
|
||||
import { EncryptionPasswordModal, ConfirmModal } from "./modals";
|
||||
import {
|
||||
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
|
||||
buildDefaultBindings, COMMAND_META, type CommandBindingMap,
|
||||
|
|
@ -936,6 +936,17 @@ export default class StashpadPlugin extends Plugin {
|
|||
// aren't surfaced as Stashpad folders) — the entry point for encrypting a
|
||||
// folder straight from Obsidian's file explorer.
|
||||
menu.addItem((item) => item.setTitle("🔒 Encrypt with Stashpad").setIcon("lock").onClick(() => void this.encryptFolderFromExplorer(path)));
|
||||
// Offer "Decrypt with Stashpad" only when this looks like a raw-folder bundle: a
|
||||
// `.stashenc` child in a folder that is NOT a Stashpad-notes folder. (A Stashpad
|
||||
// folder's `.stashenc` children are locked NOTES, not a bundle — showing Decrypt
|
||||
// there would be a confusing no-op.) The menu builder is sync, so we can't read the
|
||||
// sidecar kind here; the click handler validates via rawFolderBlobIn. This keeps the
|
||||
// 90% case clean — only a fully-locked Stashpad folder (no discoverable notes) could
|
||||
// still mis-show it, and the handler no-ops gracefully there.
|
||||
const hasBlob = file.children?.some((c) => c instanceof TFile && c.extension === "stashenc");
|
||||
if (hasBlob && !this.folderHasStashpadNotes(path)) {
|
||||
menu.addItem((item) => item.setTitle("🔓 Decrypt with Stashpad").setIcon("unlock").onClick(() => void this.decryptFolderFromExplorer(path)));
|
||||
}
|
||||
if (!this.discoverStashpadFolders().includes(path)) return;
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
|
|
@ -1712,6 +1723,19 @@ export default class StashpadPlugin extends Plugin {
|
|||
}
|
||||
},
|
||||
});
|
||||
// Gap 4: raw-folder bundles only surfaced via the file-explorer right-click. Add a
|
||||
// command-palette path so you can find & decrypt a bundle without hunting for it.
|
||||
this.addCommand({
|
||||
id: "stashpad-decrypt-folder-bundle",
|
||||
name: "Decrypt a folder bundle (encrypted non-Stashpad folder)…",
|
||||
callback: async () => {
|
||||
if (!this.encryption.isConfigured()) { new Notice("Stashpad encryption isn't set up."); return; }
|
||||
const bundles = await listRawFolderBlobs(this.app);
|
||||
if (!bundles.length) { new Notice("No encrypted folder bundles found in this vault."); return; }
|
||||
new FolderBundleSuggest(this.app, bundles, (b) => void this.decryptFolderFromExplorer(b.folder)).open();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "stashpad-build-jd-index",
|
||||
name: "Build JD index notes (creates Stashpad-note hierarchy)",
|
||||
|
|
@ -3480,18 +3504,51 @@ export default class StashpadPlugin extends Plugin {
|
|||
* protected `.stash` — original blob untouched (feedback #4 / Option B). */
|
||||
exportLockedSubtree(blobPath: string): Promise<void> { return cmdExportLockedBlob(this, blobPath); }
|
||||
|
||||
/** "Encrypt with Stashpad" from the file-explorer folder menu. Gives the folder its
|
||||
* own Stashpad password (works for any folder path, incl. import-excluded
|
||||
* subfolders) and locks any Stashpad notes inside it. */
|
||||
async encryptFolderFromExplorer(folder: string): Promise<void> {
|
||||
if (this.encryption.hasOwnFolderKey(folder)) { if (await this.ensureFolderUnlocked(folder)) await this.lockFolder(folder); return; }
|
||||
if (!this.encryption.isConfigured()) { new Notice("Set up Stashpad vault encryption first (Settings → Stashpad → Encryption), then try again."); return; }
|
||||
const name = folder.split("/").pop() || folder;
|
||||
/** Does `folder` contain (or sit within, or contain) Stashpad notes? This is the
|
||||
* CONTENT-based discriminator for "lock the notes" vs "bundle arbitrary files" — it
|
||||
* deliberately does NOT consult the key registry, because a folder keeps its key after
|
||||
* a raw bundle is decrypted, and key-presence would then mis-route a re-encrypt (gap 2)
|
||||
* and mis-show the Decrypt menu item (gap 1). The `d.startsWith(f + "/")` arm is
|
||||
* protective: if `folder` is an ANCESTOR of a Stashpad folder, treat it as Stashpad so
|
||||
* we never raw-bundle (zip + delete) live Stashpad notes nested inside it. */
|
||||
private folderHasStashpadNotes(folder: string): boolean {
|
||||
const f = folder.replace(/\/+$/, "");
|
||||
return this.discoverStashpadFolders().some((d) => f === d || f.startsWith(d + "/") || d.startsWith(f + "/"));
|
||||
}
|
||||
|
||||
private encStamp(): string {
|
||||
const d = new Date(); const p = (n: number) => String(n).padStart(2, "0");
|
||||
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
private folderKeyLabelFor(name: string): string {
|
||||
const who = (this.settings.authorName || "").trim();
|
||||
const initials = who ? who.split(/\s+/).map((w) => w[0]).join("") : (this.settings.authorId || "anon").slice(0, 4);
|
||||
const label = `${stamp} - ${name} - ${initials}`;
|
||||
return `${this.encStamp()} - ${name} - ${initials}`;
|
||||
}
|
||||
|
||||
/** "Encrypt with Stashpad" from the file-explorer folder menu. For a Stashpad folder,
|
||||
* gives it its own password and locks the Stashpad notes inside. For an ARBITRARY
|
||||
* (non-Stashpad) folder, warns via a modal then bundles every file in it into a
|
||||
* single encrypted `.stashenc`, deleting the originals (decrypt all at once). */
|
||||
async encryptFolderFromExplorer(folder: string): Promise<void> {
|
||||
if (!this.encryption.isConfigured()) { new Notice("Set up Stashpad vault encryption first (Settings → Stashpad → Encryption), then try again."); return; }
|
||||
const name = folder.split("/").pop() || folder;
|
||||
|
||||
// Gap 2: route on CONTENT, not key-presence. A folder previously raw-encrypted then
|
||||
// decrypted still owns a key, but it's not a Stashpad-notes folder, so it must route
|
||||
// back to the raw path — not the note-locker (which would silently no-op).
|
||||
if (this.folderHasStashpadNotes(folder)) { await this.encryptStashpadFolder(folder, name); return; }
|
||||
|
||||
// Already a raw bundle in there? Don't re-bundle — point the user at Decrypt.
|
||||
if (await rawFolderBlobIn(this.app, folder)) { new Notice(`“${name}” is already encrypted as a Stashpad bundle. Use “Decrypt with Stashpad” to open it.`); return; }
|
||||
await this.encryptRawFolder(folder, name);
|
||||
}
|
||||
|
||||
/** Stashpad-folder branch: own password + lock the notes inside. */
|
||||
private async encryptStashpadFolder(folder: string, name: string): Promise<void> {
|
||||
if (this.encryption.hasOwnFolderKey(folder)) { if (await this.ensureFolderUnlocked(folder)) await this.lockFolder(folder); return; }
|
||||
const label = this.folderKeyLabelFor(name);
|
||||
new EncryptionPasswordModal(this.app, {
|
||||
mode: "setup", offerKeychain: true, title: `Encrypt “${name}” with Stashpad`,
|
||||
intro: "Give this folder its own password. Any Stashpad notes inside it get encrypted under it; you'll re-enter the password to unlock.",
|
||||
|
|
@ -3506,6 +3563,71 @@ export default class StashpadPlugin extends Plugin {
|
|||
}).open();
|
||||
}
|
||||
|
||||
/** Non-Stashpad branch: confirm the all-at-once model, then bundle the whole folder.
|
||||
* Reuses an existing folder key if present (gap 2 re-encrypt case) so we don't trip
|
||||
* setupFolderKey's "refuse if key exists" guard. */
|
||||
private async encryptRawFolder(folder: string, name: string): Promise<void> {
|
||||
const runBundle = async (dek: Uint8Array): Promise<string | null> => {
|
||||
const keyId = this.encryption.folderKeyEntry(folder)?.keyId;
|
||||
try {
|
||||
const r = await lockRawFolder(this.app, folder, dek, keyId, this.encStamp());
|
||||
if (r.unpurged.length) new Notice(`Encrypted “${name}” (${r.fileCount} files) — but ${r.unpurged.length} file(s) changed mid-encrypt and were left in place.`);
|
||||
else new Notice(`Encrypted “${name}” — ${r.fileCount} file(s) bundled into one encrypted file.`);
|
||||
} catch (e) { return (e as Error).message; }
|
||||
this.refreshFolderPanels?.();
|
||||
return null;
|
||||
};
|
||||
new ConfirmModal(
|
||||
this.app,
|
||||
`Encrypt “${name}” — non-Stashpad folder`,
|
||||
// Gap 3: be explicit that this is NOT Cmd+Z-undoable — the reversal is Decrypt. We
|
||||
// hard-delete (not trash) the originals ON PURPOSE so no plaintext copy is left
|
||||
// behind in `.trash`, which would defeat the encryption.
|
||||
"This isn't a Stashpad folder, so its files can't be locked individually.\nStashpad will bundle EVERY file in this folder into one encrypted file and permanently delete the originals (no copy left in Obsidian's trash — that would defeat encryption).\nThis can't be undone with Cmd+Z; the only way back is “Decrypt with Stashpad” + the password. Continue?",
|
||||
"Bundle & encrypt",
|
||||
(confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
// Reuse an existing key (decrypt→re-encrypt cycle); else prompt for a new one.
|
||||
if (this.encryption.hasOwnFolderKey(folder)) {
|
||||
void (async () => {
|
||||
const dek = await this.ensureFolderUnlocked(folder);
|
||||
if (!dek) return; // prompts/notices on failure
|
||||
const err = await runBundle(dek);
|
||||
if (err) new Notice(err);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const label = this.folderKeyLabelFor(name);
|
||||
new EncryptionPasswordModal(this.app, {
|
||||
mode: "setup", offerKeychain: true, title: `Encrypt “${name}” with Stashpad`,
|
||||
intro: "Set a password for this bundle. You'll re-enter it to decrypt the whole folder.",
|
||||
onSubmit: async ({ next, remember }) => {
|
||||
if (!next) return "Enter a password.";
|
||||
try { await this.encryption.setupFolderKey(folder, next, label, remember); } catch (e) { return (e as Error).message; }
|
||||
const dek = this.encryption.getFolderKey(folder);
|
||||
if (!dek) return "Couldn't derive the folder key.";
|
||||
return await runBundle(dek);
|
||||
},
|
||||
}).open();
|
||||
},
|
||||
).open();
|
||||
}
|
||||
|
||||
/** "Decrypt with Stashpad" on a folder holding a raw-folder bundle: unlock the
|
||||
* folder's key, then unzip the bundle back into the folder and remove the blob. */
|
||||
async decryptFolderFromExplorer(folder: string): Promise<void> {
|
||||
const blob = await rawFolderBlobIn(this.app, folder);
|
||||
if (!blob) { new Notice("No Stashpad bundle found in this folder."); return; }
|
||||
const name = folder.split("/").pop() || folder;
|
||||
const dek = await this.ensureFolderUnlocked(folder);
|
||||
if (!dek) return; // ensureFolderUnlocked prompts/notices on failure
|
||||
try {
|
||||
const r = await unlockRawFolder(this.app, blob, dek);
|
||||
new Notice(`Decrypted “${name}” — restored ${r.filesWritten} file(s).`);
|
||||
} catch (e) { new Notice(`Couldn't decrypt: ${(e as Error).message}`); }
|
||||
this.refreshFolderPanels?.();
|
||||
}
|
||||
|
||||
// --- Phase B: per-folder key ROTATION (true invalidation — re-encrypt) ---
|
||||
private rotationLockPath(folder: string): string {
|
||||
const cleaned = folder.replace(/\/+$/, "");
|
||||
|
|
@ -5766,3 +5888,25 @@ class ImportTargetModal extends SuggestModal<ImportTarget> {
|
|||
}
|
||||
onChooseSuggestion(item: ImportTarget): void { this.onPick(item.folder); }
|
||||
}
|
||||
|
||||
/** Picker for the "Decrypt a folder bundle…" command (gap 4): lists every raw-folder
|
||||
* `.stashenc` bundle in the vault so you can decrypt one without finding it manually. */
|
||||
class FolderBundleSuggest extends SuggestModal<{ folder: string; blobPath: string }> {
|
||||
constructor(
|
||||
app: import("obsidian").App,
|
||||
private bundles: { folder: string; blobPath: string }[],
|
||||
private onPick: (b: { folder: string; blobPath: string }) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.setPlaceholder("Choose an encrypted folder bundle to decrypt…");
|
||||
}
|
||||
getSuggestions(query: string): { folder: string; blobPath: string }[] {
|
||||
const q = query.toLowerCase();
|
||||
return this.bundles.filter((b) => b.folder.toLowerCase().includes(q));
|
||||
}
|
||||
renderSuggestion(item: { folder: string; blobPath: string }, el: HTMLElement): void {
|
||||
el.createDiv({ text: item.folder || "(vault root)" });
|
||||
el.createDiv({ cls: "stashpad-suggest-note", text: item.blobPath });
|
||||
}
|
||||
onChooseSuggestion(item: { folder: string; blobPath: string }): void { this.onPick(item); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -956,6 +956,15 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
};
|
||||
}
|
||||
|
||||
/** A group HEADING + divider (like the "Sharing" header) as a standalone item, to
|
||||
* visually divide a settings tab into sections. */
|
||||
private headingDef(name: string, desc?: string): SettingDefinitionItem {
|
||||
return this.sectionDef(name, desc ?? "", (host) => {
|
||||
const s = new Setting(host).setName(name).setHeading();
|
||||
if (desc) s.setDesc(desc);
|
||||
}, [name.toLowerCase()]);
|
||||
}
|
||||
|
||||
// ---- Per-folder encryption (per-folder overhaul) ----
|
||||
/** "YYYY-MM-DD HH:mm – folder – author|authorId" key label (overhaul plan #3). */
|
||||
private folderKeyLabel(folder: string): string {
|
||||
|
|
@ -1028,6 +1037,9 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
host.createEl("p", { cls: "setting-item-description" }).setText("Set up vault encryption above first. Per-folder passwords layer on top: a folder with its own password uses a separate key; folders without one use the vault password.");
|
||||
return;
|
||||
}
|
||||
// Merged archive + trash caution (the same warnings the folder-panel modal shows
|
||||
// on the action) — surfaced here at the start of the folder section.
|
||||
host.createEl("p", { cls: "setting-item-description stashpad-enc-warning" }).setText("⚠️ Encryption has no recovery — if you lose a password, anything encrypted under it (notes, archived items, and encrypted-trash items) is gone for good. Locking / archiving / secure-deleting permanently removes the plaintext; the encrypted copy is the only one left. A plaintext archive only de-indexes notes from search — it does NOT encrypt them.");
|
||||
const folders = this.plugin.discoverStashpadFolders();
|
||||
if (folders.length === 0) { host.createEl("p", { cls: "setting-item-description" }).setText("No Stashpad folders found yet."); return; }
|
||||
// Keep a VALID selection across re-renders (default to the first folder).
|
||||
|
|
@ -1728,6 +1740,7 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
} // end SHOW_DEVICE_APPROVAL_UI
|
||||
}, ["encryption", "encrypt", "password", "passphrase", "lock", "unlock", "key", "security", "private", "collaborator", "share", "team", "member", "approve"]));
|
||||
|
||||
items.push(this.headingDef("Trash & title defaults", "Vault-wide defaults for deleted notes and locked-note titles. Per-folder overrides live under “Per-Folder Passwords” below."));
|
||||
items.push(this.renderDef("Encrypt items sent to trash", "When ON, deleting a note sends it to Stashpad's encrypted trash (recoverable with your password) instead of a plaintext trash. Default OFF.", (s) =>
|
||||
s.addToggle((t) => t.setValue(this.plugin.settings.encryptTrash).onChange(async (v) => {
|
||||
if (v && !this.encryptionOrOnboard()) { this.update?.(); return; }
|
||||
|
|
@ -1754,6 +1767,7 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
this.plugin.refreshAllStashpadViews?.();
|
||||
})), ["title", "hide", "private", "lock", "placeholder", "visibility"]));
|
||||
|
||||
items.push(this.headingDef("Archive", "Archive folders are de-indexed from cross-folder search; mark a folder as an archive (and set whether it encrypts) under “Per-Folder Passwords” below."));
|
||||
items.push(this.renderDef("Default archive folder", "Where the \"Move selection to archive\" command sends notes (they're auto-encrypted on arrival). Leaving this blank is fine — the command will just show you a list of your archive folders to pick from each time (or use the only one if you have a single archive). Mark a folder as an archive via the folder panel → right-click → \"Mark as archive\".", (s) => {
|
||||
const archives = this.plugin.settings.archiveFolders ?? [];
|
||||
s.addDropdown((d) => {
|
||||
|
|
|
|||
|
|
@ -4731,3 +4731,6 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
|
|||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-version-add:hover { color: var(--text-normal); }
|
||||
|
||||
/* Encryption settings: merged archive+trash caution at the top of the per-folder section. */
|
||||
.stashpad-enc-warning { color: var(--text-warning, var(--text-muted)); background: var(--background-modifier-error-hover, transparent); border-left: 3px solid var(--text-warning, var(--text-muted)); padding: 8px 12px; border-radius: 4px; margin: 4px 0 12px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue