mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
vault: files sealed under a password that is never stored
Argon2id turns the vault password into a key that seals a random master key; only the sealed copy is kept. Each file gets its own key, sealed under the master key in its header with its media id, and its bytes are AES-256-GCM STREAM records, so a swapped, cut or altered file refuses to open. Uploads with ?encrypt=true are sealed as they stream in. :vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on logout and on restart; while locked, sealed files answer 423, share links never serve them, and :export! refuses rather than leave them out. Sealing an existing file rewrites it under a new id and retires the old one: the mirror deletes the plaintext from Proton Drive outright, trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from the readable files/ view. The mirror now compares on-disk sizes, so sealed files are not re-hashed every run.
This commit is contained in:
1 parent
364ba61963
commit
a641d9b6ba
33 files changed
+1573
-54
No files matched your search
+22
-3
@@ -12,6 +12,7 @@
|
||||
import Overview from './lib/Overview.svelte'
|
||||
import MediaBrowser from './lib/MediaBrowser.svelte'
|
||||
import Prompt from './lib/Prompt.svelte'
|
||||
import SecretPrompt from './lib/SecretPrompt.svelte'
|
||||
import Rail from './lib/Rail.svelte'
|
||||
import Shared from './lib/Shared.svelte'
|
||||
import Sidebar from './lib/Sidebar.svelte'
|
||||
@@ -31,6 +32,8 @@
|
||||
let cmdGlobal = $state(false)
|
||||
let fileInput = $state<HTMLInputElement>()
|
||||
let treeInput = $state<HTMLInputElement>()
|
||||
/** Whether the files being picked go into the vault — `:upload!`. */
|
||||
let sealUpload = false
|
||||
let folderInput = $state<HTMLInputElement>()
|
||||
let importInput = $state<HTMLInputElement>()
|
||||
let shell = $state<HTMLDivElement>()
|
||||
@@ -45,7 +48,10 @@
|
||||
|
||||
$effect(() => {
|
||||
hooks.pickFiles = () => fileInput?.click()
|
||||
hooks.pickTreeFiles = () => treeInput?.click()
|
||||
hooks.pickTreeFiles = (encrypt) => {
|
||||
sealUpload = encrypt
|
||||
treeInput?.click()
|
||||
}
|
||||
hooks.pickFolder = () => folderInput?.click()
|
||||
hooks.pickArchive = () => importInput?.click()
|
||||
hooks.focusList = () => shell?.focus()
|
||||
@@ -92,7 +98,10 @@
|
||||
$effect(() => {
|
||||
if (!workspace.user) return
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === 'visible') void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
|
||||
if (document.visibilityState !== 'visible') return
|
||||
void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
|
||||
// The vault locks itself when left alone; the bar should say so.
|
||||
void workspace.loadVault()
|
||||
}
|
||||
const timer = setInterval(refresh, 5 * 60 * 1000)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
@@ -102,6 +111,15 @@
|
||||
}
|
||||
})
|
||||
|
||||
// The vault closes itself after a quiet spell; look again just after it
|
||||
// would have, so the bar does not go on saying it is open.
|
||||
$effect(() => {
|
||||
const vault = workspace.vault
|
||||
if (!vault?.unlocked || vault.locks_in === null) return
|
||||
const timer = setTimeout(() => void workspace.loadVault(), (vault.locks_in + 2) * 1000)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
// Guard against losing a half-written entry to a stray refresh.
|
||||
$effect(() => {
|
||||
if (!workspace.dirty) return
|
||||
@@ -126,7 +144,7 @@
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
if (files.length === 0) return
|
||||
await workspace.guard(() => workspace.putFiles(files))
|
||||
await workspace.guard(() => workspace.putFiles(files, sealUpload))
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -687,6 +705,7 @@
|
||||
{/if}
|
||||
|
||||
<Prompt />
|
||||
<SecretPrompt />
|
||||
|
||||
{#if workspace.overlay !== 'none'}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
const node = $derived(workspace.open!)
|
||||
const file = $derived(node.file!)
|
||||
/* A sealed file cannot be fetched while the vault is shut. */
|
||||
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
|
||||
const kind = $derived(file.mime.split('/')[0])
|
||||
/* What the browser can show by itself; everything else is a download. */
|
||||
const viewable = $derived(file.mime !== 'application/octet-stream')
|
||||
@@ -18,7 +20,7 @@
|
||||
let text = $state<string | null>(null)
|
||||
$effect(() => {
|
||||
text = null
|
||||
if (file.mime !== 'text/plain') return
|
||||
if (file.mime !== 'text/plain' || locked) return
|
||||
const url = file.url
|
||||
void head(url, TEXT_PREVIEW).then((body) => {
|
||||
if (body !== null && url === file.url) text = body
|
||||
@@ -77,13 +79,24 @@
|
||||
<dd>{file.mime}</dd>
|
||||
<dt>uploaded</dt>
|
||||
<dd title={formatStamp(node.created_at)}>{relative(node.created_at)}</dd>
|
||||
{#if file.encrypted}
|
||||
<dt>vault</dt>
|
||||
<dd class="light">{locked ? 'sealed, locked' : 'sealed, open'}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
{#if viewable}
|
||||
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
|
||||
{#if locked}
|
||||
<button onclick={() => runCommand('unlock')}>unlock</button>
|
||||
{:else}
|
||||
{#if viewable}
|
||||
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
|
||||
{/if}
|
||||
<a class="button" href={file.url} download={node.name}>download</a>
|
||||
{/if}
|
||||
<a class="button" href={file.url} download={node.name}>download</a>
|
||||
<button onclick={() => runCommand(file.encrypted ? 'decrypt' : 'encrypt')}>
|
||||
{file.encrypted ? 'decrypt' : 'encrypt'}
|
||||
</button>
|
||||
<button class="danger" onclick={() => runCommand('d')}>delete</button>
|
||||
<button class="close" onclick={() => runCommand('q')}>close</button>
|
||||
</div>
|
||||
@@ -92,8 +105,13 @@
|
||||
|
||||
<div class="body" bind:this={reader}>
|
||||
<h1 class="name">{node.name}</h1>
|
||||
{#key file.url}
|
||||
{#if kind === 'image'}
|
||||
{#key `${file.url}:${locked}`}
|
||||
{#if locked}
|
||||
<p class="sealed">
|
||||
<Icon name="lock" size={16} />
|
||||
sealed in the vault — <button onclick={() => runCommand('unlock')}>unlock</button> to open it.
|
||||
</p>
|
||||
{:else if kind === 'image'}
|
||||
<img src={file.url} alt={node.name} />
|
||||
{:else if kind === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
@@ -197,6 +215,9 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.light { color: var(--light); }
|
||||
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
|
||||
|
||||
.back { display: none; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
documents: 'documents',
|
||||
editing: 'writing',
|
||||
sharing: 'sharing & export',
|
||||
vault: 'vault',
|
||||
view: 'session',
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
shrink: ['M9 4v5H4', 'M15 4v5h5', 'M9 20v-5H4', 'M15 20v-5h5'],
|
||||
search: ['M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14z', 'M20 20l-4-4'],
|
||||
prompt: ['M5 7l5 5-5 5', 'M12 18h7'],
|
||||
lock: ['M6 11h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z', 'M8 11V8a4 4 0 0 1 8 0v3'],
|
||||
help: [
|
||||
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z',
|
||||
'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6',
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
{#each workspace.media as file (file.id)}
|
||||
<li>
|
||||
<div class="thumb">
|
||||
{#if file.mime.startsWith('image/')}
|
||||
{#if file.encrypted}
|
||||
<span class="label">sealed</span>
|
||||
{:else if file.mime.startsWith('image/')}
|
||||
<img src={file.url} alt={file.filename} loading="lazy" />
|
||||
{:else}
|
||||
<span class="label">{file.mime.split('/')[0]}</span>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The password `workspace.askSecret` asks for, in the same place as the
|
||||
* `[y/N]` prompt. A masked field of its own rather than the command line, so
|
||||
* a password never shows on screen or lands in the command history.
|
||||
*/
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
let value = $state('')
|
||||
let input = $state<HTMLInputElement>()
|
||||
|
||||
$effect(() => {
|
||||
if (workspace.secret) {
|
||||
value = ''
|
||||
input?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
workspace.secret?.resolve(value)
|
||||
value = ''
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
workspace.secret?.resolve(null)
|
||||
value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if workspace.secret}
|
||||
<div class="prompt" role="dialog" aria-label={workspace.secret.text}>
|
||||
<label class="text" for="secret">{workspace.secret.text}</label>
|
||||
<input
|
||||
id="secret"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
bind:this={input}
|
||||
bind:value
|
||||
{onkeydown}
|
||||
/>
|
||||
<button
|
||||
onclick={() => {
|
||||
workspace.secret?.resolve(null)
|
||||
value = ''
|
||||
}}>esc</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.prompt {
|
||||
position: absolute;
|
||||
left: calc(var(--frame) + 12px);
|
||||
right: calc(var(--rail-w) + 12px);
|
||||
bottom: calc(var(--status-h) - 1px);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--light-dim);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-l) var(--radius-l) 0 0;
|
||||
font-size: 13px;
|
||||
animation: rise var(--t-slow) var(--ease-snap) both;
|
||||
}
|
||||
.text { flex: none; color: var(--light); }
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rim);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--light);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
input:focus { border-color: var(--light-dim); outline: none; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.prompt { left: 8px; right: 8px; flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { isContainer, type NodeSummary } from './api'
|
||||
import Icon from './Icon.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatBytes, formatDay, formatTime, formatWeekday } from './util'
|
||||
|
||||
@@ -92,6 +93,7 @@
|
||||
<span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span>
|
||||
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
|
||||
{#if node.shared}<span class="shared" title="shared"></span>{/if}
|
||||
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
|
||||
<span class="aside">{aside(node)}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -215,6 +217,8 @@
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.sealed { flex: none; display: grid; color: var(--muted); }
|
||||
.row.active .sealed { color: var(--ink); }
|
||||
.empty { padding: 12px 10px; font-size: 12px; }
|
||||
|
||||
/* Roomier tap targets where there is no keyboard. */
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
|
||||
|
||||
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
|
||||
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :lock closes it">vault open</span>{/if}
|
||||
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
|
||||
<span class="hint">
|
||||
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
|
||||
|
||||
+32
-2
@@ -10,6 +10,8 @@ export interface FileInfo {
|
||||
mime: string
|
||||
size: number
|
||||
url: string
|
||||
/** Sealed in the vault: it opens only while the vault is unlocked. */
|
||||
encrypted: boolean
|
||||
}
|
||||
|
||||
export interface NodeSummary {
|
||||
@@ -125,10 +127,19 @@ export interface MediaFile {
|
||||
size: number
|
||||
created_at: number
|
||||
url: string
|
||||
encrypted: boolean
|
||||
/** Every document that embeds this file — a file may be used by several. */
|
||||
node_ids: number[]
|
||||
}
|
||||
|
||||
/** The vault, as `/api/vault` reports it. */
|
||||
export interface VaultStatus {
|
||||
configured: boolean
|
||||
unlocked: boolean
|
||||
/** Seconds until it locks itself if nothing uses it. */
|
||||
locks_in: number | null
|
||||
}
|
||||
|
||||
/** The Proton Drive mirror, as `/api/backup` reports it. */
|
||||
export interface BackupStatus {
|
||||
configured: boolean
|
||||
@@ -285,12 +296,12 @@ export const api = {
|
||||
* Upload files into a space or folder as file nodes. XHR rather than fetch,
|
||||
* because a gigabyte going up needs to say how far along it is.
|
||||
*/
|
||||
putFiles: (parentId: number, files: File[], progress?: (fraction: number) => void) =>
|
||||
putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) =>
|
||||
new Promise<WorkspaceNode[]>((resolve, reject) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file)
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `/api/nodes/${parentId}/files`)
|
||||
xhr.open('POST', `/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`)
|
||||
xhr.responseType = 'json'
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) progress?.(event.loaded / event.total)
|
||||
@@ -311,6 +322,25 @@ export const api = {
|
||||
|
||||
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
|
||||
|
||||
vaultStatus: () => request<VaultStatus>('/vault'),
|
||||
|
||||
setupVault: (password: string) =>
|
||||
request<VaultStatus>('/vault', { method: 'POST', body: body({ password }) }),
|
||||
|
||||
changeVaultPassword: (current: string, password: string) =>
|
||||
request<VaultStatus>('/vault', { method: 'PUT', body: body({ current, password }) }),
|
||||
|
||||
unlockVault: (password: string) =>
|
||||
request<VaultStatus>('/vault/unlock', { method: 'POST', body: body({ password }) }),
|
||||
|
||||
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
|
||||
|
||||
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
|
||||
seal: (id: number, encrypt: boolean) =>
|
||||
request<{ files: number; unchanged: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
backupStatus: () => request<BackupStatus>('/backup'),
|
||||
|
||||
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
||||
|
||||
+107
-3
@@ -8,7 +8,7 @@ export type { ScrollAmount }
|
||||
export const hooks = {
|
||||
pickFiles: () => {},
|
||||
/** Files for the folder being browsed, rather than for the document. */
|
||||
pickTreeFiles: () => {},
|
||||
pickTreeFiles: (_encrypt: boolean) => {},
|
||||
pickFolder: () => {},
|
||||
pickArchive: () => {},
|
||||
focusEditor: () => {},
|
||||
@@ -24,7 +24,7 @@ export const hooks = {
|
||||
}
|
||||
|
||||
/** Help groups the command list under these headings, in this order. */
|
||||
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'view'] as const
|
||||
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'vault', 'view'] as const
|
||||
export type Group = (typeof groups)[number]
|
||||
|
||||
export interface CommandSpec {
|
||||
@@ -62,6 +62,29 @@ const findFolder = (target: string) => {
|
||||
)
|
||||
}
|
||||
|
||||
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
|
||||
const sealTarget = (encrypt: boolean) =>
|
||||
workspace.guard(async () => {
|
||||
const node = workspace.target
|
||||
if (!node || node.kind === 'document') {
|
||||
return workspace.say('only files go into the vault — put the cursor on a file or a folder', 'error')
|
||||
}
|
||||
if (node.file && node.file.encrypted === encrypt) {
|
||||
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
|
||||
}
|
||||
if (isContainer(node)) {
|
||||
const verb = encrypt ? 'seal every file in' : 'take every file in'
|
||||
const tail = encrypt ? 'into the vault' : 'out of the vault'
|
||||
if (!(await workspace.ask(`${verb} ${node.kind} "${node.name || node.slug}" ${tail}?`))) return
|
||||
} else if (!encrypt) {
|
||||
const name = node.name || node.slug
|
||||
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
|
||||
}
|
||||
if (!(await workspace.ensureUnlocked())) return
|
||||
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
|
||||
await workspace.seal(node.id, encrypt)
|
||||
})
|
||||
|
||||
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
|
||||
const ago = (at: number | null) => {
|
||||
if (!at) return 'never'
|
||||
@@ -622,7 +645,14 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['up'],
|
||||
group: 'editing',
|
||||
help: 'while writing, attach files at the cursor — otherwise put files into this folder',
|
||||
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()),
|
||||
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles(false)),
|
||||
bang: {
|
||||
help: 'put files into this folder sealed in the vault — encrypted before they touch the disk',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'media',
|
||||
@@ -753,6 +783,80 @@ export const commands: CommandSpec[] = [
|
||||
workspace.say('usage: :sshkey [add <name> <key> | rm <name>]', 'error')
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'vault',
|
||||
group: 'vault',
|
||||
help: 'whether the vault is open — or, the first time, set its password',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
await workspace.loadVault()
|
||||
const vault = workspace.vault
|
||||
if (vault?.configured) {
|
||||
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
|
||||
return workspace.say(vault.unlocked ? `vault open${left} — :lock closes it` : 'vault locked — :unlock opens it')
|
||||
}
|
||||
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
|
||||
if (password === null) return
|
||||
const again = await workspace.askSecret('the same again')
|
||||
if (again === null) return
|
||||
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
|
||||
workspace.vault = await api.setupVault(password)
|
||||
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload! seals new files')
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'unlock',
|
||||
group: 'vault',
|
||||
help: 'open the vault with its password, so sealed files can be read',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
await workspace.loadVault()
|
||||
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
|
||||
await workspace.ensureUnlocked()
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'lock',
|
||||
group: 'vault',
|
||||
help: 'close the vault now — it also closes itself when left alone, and on logout',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
workspace.vault = await api.lockVault()
|
||||
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
|
||||
workspace.say('vault locked')
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'passwd',
|
||||
group: 'vault',
|
||||
help: "change the vault's password — no file is rewritten",
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
const current = await workspace.askSecret('current vault password')
|
||||
if (current === null) return
|
||||
const password = await workspace.askSecret('new vault password, 10+ characters')
|
||||
if (password === null) return
|
||||
const again = await workspace.askSecret('the same again')
|
||||
if (again === null) return
|
||||
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
|
||||
workspace.vault = await api.changeVaultPassword(current, password)
|
||||
workspace.say('vault password changed')
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'encrypt',
|
||||
aliases: ['seal'],
|
||||
group: 'vault',
|
||||
help: 'seal the file under the cursor in the vault — on a folder, every file inside it',
|
||||
run: () => sealTarget(true),
|
||||
},
|
||||
{
|
||||
name: 'decrypt',
|
||||
aliases: ['unseal'],
|
||||
group: 'vault',
|
||||
help: 'take the file under the cursor back out of the vault — on a folder, every file inside it',
|
||||
run: () => sealTarget(false),
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
aliases: ['h'],
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type MediaFile,
|
||||
type NodeSummary,
|
||||
type SpaceOverview,
|
||||
type VaultStatus,
|
||||
} from './api'
|
||||
import { formatBytes } from './util'
|
||||
|
||||
@@ -120,6 +121,10 @@ class Workspace {
|
||||
* keyboard without a modal the rest of the page cannot see past.
|
||||
*/
|
||||
prompt = $state<{ text: string; resolve: (yes: boolean) => void } | null>(null)
|
||||
/** A password asked for in the same place, typed into a masked field. */
|
||||
secret = $state<{ text: string; resolve: (value: string | null) => void } | null>(null)
|
||||
/** The vault, as last heard from the server; null until asked. */
|
||||
vault = $state<VaultStatus | null>(null)
|
||||
media = $state<MediaFile[]>([])
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
overviewCursor = $state(0)
|
||||
@@ -198,6 +203,53 @@ class Workspace {
|
||||
return { late: overdue.cards.length, today: today.cards.length }
|
||||
}
|
||||
|
||||
/** A password, or null when it was waved away with Escape. */
|
||||
askSecret(text: string): Promise<string | null> {
|
||||
this.secret?.resolve(null)
|
||||
return new Promise((resolve) => {
|
||||
this.secret = {
|
||||
text,
|
||||
resolve: (value) => {
|
||||
this.secret = null
|
||||
resolve(value)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async loadVault() {
|
||||
this.vault = await api.vaultStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whatever needs the vault asks for the password itself rather than failing
|
||||
* with "locked": opening it is the obvious next step anyway. False when
|
||||
* there is no vault, or the question was declined.
|
||||
*/
|
||||
async ensureUnlocked(): Promise<boolean> {
|
||||
await this.loadVault()
|
||||
if (!this.vault?.configured) {
|
||||
this.say('there is no vault yet — :vault sets one up', 'error')
|
||||
return false
|
||||
}
|
||||
if (this.vault.unlocked) return true
|
||||
const password = await this.askSecret('vault password')
|
||||
if (password === null) return false
|
||||
this.vault = await api.unlockVault(password)
|
||||
this.say('vault unlocked')
|
||||
return true
|
||||
}
|
||||
|
||||
/** Seal what is under the cursor, or take it back out, and show the result. */
|
||||
async seal(id: number, encrypt: boolean) {
|
||||
const { files, unchanged } = await api.seal(id, encrypt)
|
||||
const verb = encrypt ? 'encrypted' : 'decrypted'
|
||||
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
|
||||
this.say(`${verb} ${files} file${files === 1 ? '' : 's'}${already}`)
|
||||
await this.refresh()
|
||||
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id)
|
||||
}
|
||||
|
||||
ask(text: string): Promise<boolean> {
|
||||
this.prompt?.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
@@ -324,6 +376,7 @@ class Workspace {
|
||||
this.user = user
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
await this.loadAgenda()
|
||||
await this.loadVault()
|
||||
await this.openAgendaIfDue()
|
||||
} catch {
|
||||
this.user = null
|
||||
@@ -337,12 +390,14 @@ class Workspace {
|
||||
this.user = user
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
await this.loadAgenda()
|
||||
await this.loadVault()
|
||||
await this.openAgendaIfDue()
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await api.logout()
|
||||
this.user = null
|
||||
this.vault = null
|
||||
this.spaces = []
|
||||
this.nodes = []
|
||||
this.open = null
|
||||
@@ -1097,14 +1152,14 @@ class Workspace {
|
||||
* Upload files into the folder being browsed, as nodes of their own. The
|
||||
* status line counts up while they go, because a large one takes a while.
|
||||
*/
|
||||
async putFiles(files: File[]) {
|
||||
async putFiles(files: File[], encrypt = false) {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||
const what = files.length === 1 ? files[0].name : `${files.length} files`
|
||||
this.say(`uploading ${what} (${formatBytes(total)}) …`)
|
||||
this.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)
|
||||
|
||||
const created = await api.putFiles(parent, files, (fraction) =>
|
||||
const created = await api.putFiles(parent, files, encrypt, (fraction) =>
|
||||
this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
|
||||
)
|
||||
this.query = ''
|
||||
@@ -1141,6 +1196,10 @@ class Workspace {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
this.user = null
|
||||
this.say('session expired — log in again', 'error')
|
||||
} else if (error instanceof ApiError && error.status === 423) {
|
||||
// It locked itself while nobody was looking.
|
||||
if (this.vault) this.vault = { ...this.vault, unlocked: false, locks_in: null }
|
||||
this.say(error.message, 'error')
|
||||
} else {
|
||||
this.say(error instanceof Error ? error.message : String(error), 'error')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user