Compare commits
No commits in common. "2e4005d24dbb6e566270fe454d29636639b3ea86" and "a6329d564020c128667f24ef1a4acb572d98d449" have entirely different histories.
2e4005d24d
...
a6329d5640
20 changed files with 444 additions and 229 deletions
|
|
@ -1,122 +1,182 @@
|
||||||
# Checkpoint — Profile metadata publishing + profile pictures (2026-08-22)
|
# Checkpoint — All audit items closed (2026-08-21)
|
||||||
|
|
||||||
**App renamed to Keynectr (2026-08-23).** A stopping point you can return to if this session is closed. Everything below was
|
A stopping point you can return to if this session is closed. Everything below was
|
||||||
verified green at the moment this file was written.
|
verified green at the moment this file was written.
|
||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
- Project: `/home/avi/Projects/Nostr_Keynctr` (renamed from `0_Nostr` after commit `1116dfd`)
|
- Project: `/home/avi/Projects/0_Nostr`
|
||||||
- Git repo: `master` @ `a6329d5` ("Publish profile metadata (name + picture) so external
|
- Git repo: `master` @ `f7db29e` ("Add SSRF guard, signer queue cap, secret echo, request
|
||||||
clients show it"). Before it: `db81f8d` (profile deletion with undo), `9a8f334`
|
timeout"). Before it: `130d7e2` (zeroization), `4461307` (owner-only writes), `4bd7660`
|
||||||
(audit checkpoint refresh), and the 2026-08-21 audit-fix commits (`f7db29e`, `d90b6e5`,
|
(legacy vault perms), `4d4dfde` (CSP + navigation guards), `6e627a3` (upload tokens),
|
||||||
`130d7e2`).
|
`4bde395` (IPC allowlist).
|
||||||
- Working tree is **clean** apart from this checkpoint update, which is committed right after.
|
- Working tree is **clean** apart from this checkpoint update, which is committed right after.
|
||||||
|
|
||||||
## What was completed
|
## What was completed: the full 2026-08-21 security audit remediation
|
||||||
|
|
||||||
**Problem:** profiles created in the app never published a Nostr kind 0 metadata event,
|
All ten findings from the 2026-08-21 security audit are fixed.
|
||||||
so other clients showed generated petnames ("evil iguana", "homeless leech") or a
|
|
||||||
truncated npub instead of the user's chosen name.
|
|
||||||
|
|
||||||
1. **Automatic metadata on creation** — `create_profile` now publishes a kind 0 event
|
**#1 IPC method allowlist (`4bde395`, `main.ts` only):**
|
||||||
with the label as `name`/`display_name` to all enabled relays (best-effort; relay
|
- `RENDERER_METHODS` set in the Electron main process: the three native methods
|
||||||
failures never block creation).
|
(`pick_image`, `link_preview`, `upload_image`) plus every Rust-backend method used by
|
||||||
2. **"Publish name" action for existing profiles** — new button on every Profiles-screen
|
`frontend/src/lib/api.ts`.
|
||||||
card plus CLI `publish-name <npub>`. Returns a per-relay report shown in the UI.
|
- `isAllowedMethod()` gate at the top of the `backend:request` handler; unknown methods get
|
||||||
3. **Profile pictures end-to-end** —
|
`{status:'error', code:'unknown_method'}`, are logged, and never reach the backend.
|
||||||
- Vault: optional `picture: Option<String>` per profile (serde default → old vaults
|
- No behaviour change for the real UI (only `api.ts` calls `window.backend.request`).
|
||||||
load unchanged).
|
|
||||||
- Backend: `set_profile_picture` validates http(s) URLs only, stores the URL, and
|
|
||||||
publishes kind 0 including `picture`; clearing supported (`None`).
|
|
||||||
- GUI: "Picture" button opens a modal — paste a URL, upload a file via the existing
|
|
||||||
nostr.build pipeline, or remove; avatar shows the picture everywhere in-app.
|
|
||||||
- CLI: `set-picture <npub> <url>`.
|
|
||||||
4. **Nested-runtime safety** — metadata publishing runs on a dedicated OS thread with its
|
|
||||||
own tokio runtime, so both sync (CLI) and async (IPC server) callers are safe.
|
|
||||||
5. **Finished prior session's delete/undo work** — exposed `undo_history` in
|
|
||||||
`AppStateView`, fixed invalid Button variants / missing icon / null-safety errors so
|
|
||||||
the frontend typechecks again.
|
|
||||||
6. **Test hygiene fix** — `Settings::default()` points at real relays and tests were
|
|
||||||
silently publishing events to them (one got rate-limited by damus.io). All test suites
|
|
||||||
now use offline settings; test time dropped from ~126 s to ~3 s.
|
|
||||||
|
|
||||||
## Commits added in this session
|
**#2 Upload pick tokens (`6e627a3`):**
|
||||||
|
The renderer used to send raw filesystem paths back to main for upload, so a compromised
|
||||||
|
page could read+publish arbitrary local files to nostr.build. Now:
|
||||||
|
- `pickImage()` (`main.ts`) mints a random 32-hex-char token per picked file via
|
||||||
|
`crypto.randomBytes`; tokens map to the file record in `pickedTokens` (main-process memory
|
||||||
|
only). The renderer receives `{token, name, mime}` — it never sees any path.
|
||||||
|
- `upload_image` accepts only a valid `token`; the token is consumed (deleted) before the
|
||||||
|
upload starts, so each pick authorises exactly one upload. Unknown/used/expired tokens
|
||||||
|
return `{status:'error', code:'unknown_token', message:'That image selection has expired.
|
||||||
|
Please attach it again.'}` — the user just re-picks the image.
|
||||||
|
- Renderer updates: `PickedImage.path` → `PickedImage.token` (`types.ts`), `api.uploadImage(token)`
|
||||||
|
(`api.ts`), context type + callback (`AppProvider.tsx`), `ComposeScreen.onAttach` uses
|
||||||
|
`image.token`.
|
||||||
|
- Fake backend mirrors the contract: `upload_image` without a non-empty `token` throws an
|
||||||
|
`unknown_token` error, so tests exercise the same protocol rule.
|
||||||
|
|
||||||
- `7c6a085` Rename the app to Keynectr
|
**#3 Header-based CSP + navigation/window guards (`4d4dfde`):**
|
||||||
- `ae5ddda` Allow new profile methods through the Electron IPC allowlist
|
- The static CSP meta tag was **removed** from `frontend/index.html` and replaced with
|
||||||
- `a6329d5` Publish profile metadata (name + picture) so external clients show it
|
response headers stamped by `main.ts` (`onHeadersReceived`, mainFrame only):
|
||||||
|
- `CSP_PROD` for `app://` pages: `script-src 'self'` — **no `'unsafe-inline'`**, so an
|
||||||
|
injected `<script>` cannot execute. `connect-src 'self'` (all real network goes through
|
||||||
|
the backend/main process); img-src keeps `https:` for remote feed/preview images.
|
||||||
|
- `CSP_DEV` when `NOSTR_GUI_DEV_URL` matches: keeps `'unsafe-inline'` (Vite's React-refresh
|
||||||
|
preamble is an inline script) but pins `connect-src` to localhost instead of the old
|
||||||
|
wide-open `ws:` — HMR still works.
|
||||||
|
- The meta had to go because it would also have blocked the dev preamble; headers let us
|
||||||
|
ship strict-prod / workable-dev from one HTML file.
|
||||||
|
- `will-navigate`: any navigation away from app:// or the dev origin is blocked; http(s)
|
||||||
|
targets open in the system browser via `shell.openExternal`.
|
||||||
|
- `setWindowOpenHandler`: all `target="_blank"` popups are denied; external links go to the
|
||||||
|
system browser. Unknown schemes are denied without opening anything.
|
||||||
|
|
||||||
**Gotcha for future work:** any new backend request type must also be added to
|
**#4 Legacy vault permissions (`4bd7660`):**
|
||||||
`RENDERER_METHODS` in `frontend/electron/main.ts`, or the main process rejects it with
|
- The original CLI left `profiles_vault.json` files with default (often group-readable)
|
||||||
"rejected renderer method" before it reaches the Rust backend.
|
permissions containing plaintext keys. Now:
|
||||||
|
- On **every** startup, `load_vault()` calls the new `harden_stray_legacy_vaults()`
|
||||||
|
(`src/vault.rs`): each known legacy location is checked, and any file that parses as a
|
||||||
|
populated vault is chmod'd to 0600 — whether or not migration ever runs.
|
||||||
|
- During migration itself, the legacy file is tightened before the backup copy is made.
|
||||||
|
- Hardening is best-effort (`let _ =`) so odd filesystems can never break vault loading;
|
||||||
|
unparsable and keyless files are deliberately not touched.
|
||||||
|
- One-time local cleanup done by hand in this session: repo-root `profiles_vault.json`
|
||||||
|
was chmod'd from 0664 → 0600.
|
||||||
|
- New tests: recognised legacy vault tightened to 0600; unparsable file ignored; empty /
|
||||||
|
encrypted-but-populated vaults classified correctly.
|
||||||
|
|
||||||
## Verification commands run (all green)
|
**#5 Owner-only-from-first-byte writes (`4461307`):**
|
||||||
|
- `write_restricted` (`src/vault.rs`): the tmp file is now created with `.mode(0o600)`
|
||||||
|
(OpenOptionsExt), so it never exists under default umask permissions. The explicit
|
||||||
|
`set_permissions` stays, covering a tmp file left over by a crashed earlier run (where
|
||||||
|
`mode` would not apply to an existing file).
|
||||||
|
- `backup_file`: replaced `fs::copy` — which gives the new file the *source's* permission
|
||||||
|
bits, so backing up a group-readable legacy vault briefly produced a fully readable copy —
|
||||||
|
with a manual open-at-0600 + `io::copy`, making backups owner-only from their first byte.
|
||||||
|
- New test: backup of a 0664 source comes out 0600 with identical content.
|
||||||
|
|
||||||
Rust (repo root):
|
**#6 Key material zeroization (`130d7e2`, adds `zeroize = "1"`):**
|
||||||
|
Decrypted secrets and derived keys no longer linger in unscrubbed heap memory:
|
||||||
|
- `crypto.rs`: `decrypt_secret` returns `Zeroizing<String>` (self-shredding on drop); the
|
||||||
|
UTF-8 error path wipes the raw bytes too; a failed Argon2 derivation wipes its key buffer.
|
||||||
|
- `profiles.rs`: `resolve_secret_key` / `resolve_active_secret_key` now return
|
||||||
|
`Zeroizing<String>`, so every transient plaintext key flowing to publish/upload/signer is
|
||||||
|
wiped when its scope ends. Newly generated keys in `create_profile` are wrapped the same
|
||||||
|
way. `reveal_secret_key` still hands a display copy to the UI by design.
|
||||||
|
- `app.rs`: `lock()` and a new `Drop for App` zeroize the session vault key; wrong-password
|
||||||
|
derivations wipe their throwaway keys; password change wipes the previous vault key and
|
||||||
|
the old unlock key; `remove_password` moves decrypted values into storage via
|
||||||
|
`std::mem::take` without extra copies (plaintext-at-rest is that feature's purpose).
|
||||||
|
- Callers in `publish.rs`, `uploads.rs`, `signer.rs` needed no changes (deref coercion).
|
||||||
|
|
||||||
|
**Smaller items (`f7db29e`, main.ts + signer.rs):**
|
||||||
|
- **SSRF guard**: `fetchLinkPreview` resolves the URL's host and refuses loopback/private/
|
||||||
|
link-local targets — literal IPs, DNS answers, and localhost/.local names all checked
|
||||||
|
(`ipv4IsPrivate`/`ipv6IsPrivate`/`resolvesToPrivateAddress`). Crafted note links can no
|
||||||
|
longer make the app probe localhost or the LAN.
|
||||||
|
- **Signer queue cap** (`MAX_PENDING_APPROVALS = 20`): a relay flooding `sign_event`
|
||||||
|
requests cannot grow the approval queue unboundedly or bury a genuine prompt; overflow
|
||||||
|
requests get the standard "no decision" error.
|
||||||
|
- **NIP-46 secret echo**: when the nostrconnect:// link carried a secret, the client's
|
||||||
|
`connect` request must echo it back or it is refused ("did not include the expected
|
||||||
|
secret"), proving the link arrived unmodified.
|
||||||
|
- **Backend request timeout**: `backendRequest` reaps any round-trip after 120s (generous
|
||||||
|
enough for multi-relay publishes), deleting its pending entry so a hung backend cannot
|
||||||
|
leak promises.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- `f7db29e` "Add SSRF guard, signer queue cap, secret echo, request timeout" — main.ts +
|
||||||
|
signer.rs (+204/−3).
|
||||||
|
- `130d7e2` "Zeroize transient secret key material in memory" — Cargo.toml, crypto.rs,
|
||||||
|
profiles.rs, app.rs (+77/−23).
|
||||||
|
- `4461307` "Create vault files owner-only from the first byte" — src/vault.rs (+48/−2).
|
||||||
|
- `4bd7660` "Tighten permissions on leftover legacy vault files" — src/vault.rs (+79/−3).
|
||||||
|
- `4d4dfde` "Enforce header-based CSP and block window open/navigation" — main.ts + index.html.
|
||||||
|
- `6e627a3` "Replace upload file paths with single-use pick tokens" — main.ts, api.ts,
|
||||||
|
types.ts, AppProvider.tsx, ComposeScreen.tsx, fakeBackend.ts (+62/−27).
|
||||||
|
- `4bde395` "Restrict renderer IPC to an explicit method allowlist" — main.ts (+49 lines).
|
||||||
|
- `dafed33` "Refresh checkpoint with IPC allowlist hardening".
|
||||||
|
|
||||||
|
## How it was verified (all green)
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo test # 96 passed; 0 failed
|
cargo test # 84 passed
|
||||||
cargo clippy --all-targets # 0 errors/warnings from session code
|
cargo clippy --all-targets # clean
|
||||||
cargo fmt --check # clean
|
cargo fmt --check # clean
|
||||||
cargo build --release # success
|
cargo build --release # ok
|
||||||
```
|
npm run typecheck # clean (frontend/)
|
||||||
|
npm run lint # clean (pre-existing module-type warning only)
|
||||||
Frontend (`frontend/`):
|
|
||||||
|
|
||||||
```
|
|
||||||
npm test # 14 files, 78 tests passed
|
|
||||||
npm run typecheck # clean
|
|
||||||
npm run lint # 0 errors
|
|
||||||
npm run format:check # clean
|
npm run format:check # clean
|
||||||
npm run build # vite build success
|
npm test # 78 passed (14 files)
|
||||||
npm run electron:build # tsc electron main success
|
npm run electron:build # compiles the Electron main process
|
||||||
|
npm run build # rebuilds the React bundle (dist/)
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to use / reproduce
|
Manual protocol checks worth doing once:
|
||||||
|
- DevTools console: `window.backend.request('not_a_method')` → `unknown_method` envelope (#1).
|
||||||
|
- DevTools console: `window.backend.request('upload_image', {path:'/etc/passwd'})` →
|
||||||
|
`unknown_token` error; no file is read (#2). Real attachment flow works unchanged.
|
||||||
|
- DevTools console: `document.cookie` / injected `<script>` does not run; Application tab
|
||||||
|
shows the CSP header on the app:// document (#3). Link previews open in the system browser.
|
||||||
|
|
||||||
GUI:
|
## How to resume
|
||||||
|
|
||||||
```bash
|
1. Open the repo: `cd /home/avi/Projects/0_Nostr`
|
||||||
cd ~/Projects/Nostr_Keynctr/frontend && npm start
|
2. State is committed: `git status` should be clean; `git log --oneline -4` shows `6e627a3`
|
||||||
```
|
on top.
|
||||||
|
3. Launch as usual: `cd frontend && npm start` (backend already built in `target/release/`).
|
||||||
|
4. Re-run verification with the commands above.
|
||||||
|
|
||||||
- New profiles publish their name automatically on creation.
|
## Outstanding / next steps (if you continue)
|
||||||
- Existing profiles: **Profiles → Publish name** button, or **Picture** button to set a
|
|
||||||
photo (URL paste or file upload) which publishes immediately.
|
|
||||||
|
|
||||||
CLI:
|
Nothing outstanding from the audit — all ten findings are closed:
|
||||||
|
|
||||||
```bash
|
| # | Finding | Fix commit |
|
||||||
B=~/Projects/Nostr_Keynctr/target/release/keynectr
|
|---|---------|-----------|
|
||||||
$B list # show profiles
|
| 1 | Unrestricted renderer→backend IPC | `4bde395` |
|
||||||
$B relays enable wss://nos.lol # enable at least one relay first
|
| 2 | Arbitrary file upload paths | `6e627a3` |
|
||||||
$B publish-name <npub> # republish stored name
|
| 3 | CSP `unsafe-inline`, no nav guards | `4d4dfde` |
|
||||||
$B set-picture <npub> https://…/img.png # set + publish picture
|
| 4 | Legacy vault world-readable | `4bd7660` |
|
||||||
```
|
| 5 | Permission race windows on write | `4461307` |
|
||||||
|
| 6 | Key material not zeroized | `130d7e2` |
|
||||||
|
| 7 | Link-preview SSRF | `f7db29e` |
|
||||||
|
| 8 | Signer queue flooding | `f7db29e` |
|
||||||
|
| 9 | NIP-46 secret not verified | `f7db29e` |
|
||||||
|
| 10 | No backend request timeout | `f7db29e` |
|
||||||
|
|
||||||
Verified live during the session: kind 0 events confirmed present on nos.lol,
|
**Profile deletion with undo functionality** (2026-08-22):
|
||||||
relay.primal.net, relay.damus.io and the user's own wss://nostr.l484.com via direct
|
- Users can delete a profile via `nostr-manager-backend delete-profile <npub>`, which moves the profile to an in-memory undo stack rather than permanently removing it
|
||||||
websocket queries; Iris shows the stored names after publish.
|
- Profiles can be restored with `nostr-manager-backend undo-delete`, which pops the last deleted profile from the undo stack and re-adds it to the vault (becoming active if no other profile exists)
|
||||||
|
- The undo stack is in-memory only (lost on process exit); a persistent implementation would require vault metadata changes
|
||||||
|
- Vault must be unlocked or unencrypted for deletion to be permitted
|
||||||
|
- Existing profile data and vault integrity are preserved
|
||||||
|
- CLI commands: `delete-profile <npub>` and `undo-delete`
|
||||||
|
- Rust changes verified: `cargo test` (91 tests), `cargo clippy --all-targets` clean, `cargo fmt --check` clean, `cargo build --release` successful
|
||||||
|
|
||||||
## Notes & next steps
|
Possible future work (not audit items): DNS-rebinding TOCTOU in the SSRF guard, OS keyring integration for the vault password, an automated dependency-audit CI job (`cargo audit`, `npm audit`), GUI integration for profile deletion/undo in the ProfilesScreen.
|
||||||
|
|
||||||
- Nostr has no relay-to-relay sync: names/pictures are only visible on relays they were
|
|
||||||
published to. Keep major relays enabled so clients that don't read your own relay can
|
|
||||||
see the profile.
|
|
||||||
- Clients cache profiles; hard-refresh (Ctrl+Shift+R) after republishing.
|
|
||||||
- **Next feature (agreed 2026-08-22): NIP-05 identifiers.** Live testing showed Iris
|
|
||||||
renders names+pictures perfectly from plain kind 0 metadata, but Yakihonne only shows
|
|
||||||
a proper username handle when `nip05` is present, and some newer clients
|
|
||||||
(phoenix.social) are picky about relay coverage. Plan: add an optional NIP-05
|
|
||||||
identifier field per profile (GUI + CLI), include it in published metadata, plus docs
|
|
||||||
or a helper for serving `.well-known/nostr.json` on the user's domain (the HTTP side
|
|
||||||
cannot be done by the app alone).
|
|
||||||
- Other candidates: rename profiles (edit label + republish), banner/about fields in the
|
|
||||||
edit UI.
|
|
||||||
- **Rename details:** binary is now `keynectr`; data lives in `~/.local/share/keynectr`
|
|
||||||
(auto-migrated from `nost-feed-manager` on first run — verified live). The KDF verifier
|
|
||||||
string in crypto.rs was deliberately NOT renamed so old encrypted backups stay readable.
|
|
||||||
If a GitHub remote is ever added, rename the repo to `Nostr_Keynctr` there too.
|
|
||||||
- Relay config at time of writing (all enabled): nos.lol, relay.primal.net, l484.com
|
|
||||||
(user's own), damus.io, snort.social, soloco.nl; relay.nostr.band added but was
|
|
||||||
unreachable (handshake timeout) — retry enabling later.
|
|
||||||
|
|
|
||||||
38
Cargo.lock
generated
38
Cargo.lock
generated
|
|
@ -683,25 +683,6 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "keynectr"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"aes-gcm",
|
|
||||||
"argon2",
|
|
||||||
"base64",
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"hex",
|
|
||||||
"nostr",
|
|
||||||
"nostr-sdk",
|
|
||||||
"rpassword",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
"uuid",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
|
|
@ -800,6 +781,25 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nostr-manager-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
|
"base64",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hex",
|
||||||
|
"nostr",
|
||||||
|
"nostr-sdk",
|
||||||
|
"rpassword",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-relay-pool"
|
name = "nostr-relay-pool"
|
||||||
version = "0.40.1"
|
version = "0.40.1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "keynectr"
|
name = "nostr-manager-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,10 @@ let nextId = 1;
|
||||||
|
|
||||||
function resolveBackendPath(): string {
|
function resolveBackendPath(): string {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
return path.join(process.resourcesPath, 'keynectr');
|
return path.join(process.resourcesPath, 'nostr-manager-backend');
|
||||||
}
|
}
|
||||||
// Development: the crate builds to <project>/target/release.
|
// Development: the crate builds to <project>/target/release.
|
||||||
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
|
return path.join(app.getAppPath(), '..', 'target', 'release', 'nostr-manager-backend');
|
||||||
}
|
}
|
||||||
|
|
||||||
function startBackend(): void {
|
function startBackend(): void {
|
||||||
|
|
@ -183,10 +183,6 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'get_state',
|
'get_state',
|
||||||
'create_profile',
|
'create_profile',
|
||||||
'select_profile',
|
'select_profile',
|
||||||
'publish_profile_metadata',
|
|
||||||
'set_profile_picture',
|
|
||||||
'delete_profile',
|
|
||||||
'undo_delete',
|
|
||||||
'publish_note',
|
'publish_note',
|
||||||
'feed_get',
|
'feed_get',
|
||||||
'relay_add',
|
'relay_add',
|
||||||
|
|
@ -494,7 +490,7 @@ function createWindow(): void {
|
||||||
height: 760,
|
height: 760,
|
||||||
minWidth: 920,
|
minWidth: 920,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
title: 'Keynectr',
|
title: 'Nostr Feed Manager',
|
||||||
backgroundColor: '#f6f4f0',
|
backgroundColor: '#f6f4f0',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Keynectr</title>
|
<title>Nostr Feed Manager</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "keynectr",
|
"name": "nost-feed-manager",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "keynectr",
|
"name": "nost-feed-manager",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "keynectr",
|
"name": "nost-feed-manager",
|
||||||
"productName": "Keynectr",
|
"productName": "Nostr Feed Manager",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.",
|
"description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "dev.nostfeedmanager.desktop",
|
"appId": "dev.nostfeedmanager.desktop",
|
||||||
|
"productName": "Nostr Feed Manager",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
|
|
@ -57,8 +58,8 @@
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "../target/release/keynectr",
|
"from": "../target/release/nostr-manager-backend",
|
||||||
"to": "keynectr"
|
"to": "nostr-manager-backend"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linux": {
|
"linux": {
|
||||||
|
|
@ -66,7 +67,7 @@
|
||||||
"dir"
|
"dir"
|
||||||
],
|
],
|
||||||
"category": "Network",
|
"category": "Network",
|
||||||
"executableName": "keynectr"
|
"executableName": "nost-feed-manager"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ export function Sidebar({ screen, onNavigate }: SidebarProps) {
|
||||||
<Icon name="shield" size={22} />
|
<Icon name="shield" size={22} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Keynectr</strong>
|
<strong>Nostr Feed</strong>
|
||||||
|
<span className="sidebar-subtitle">Manager</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
<div className="screen-inner">
|
<div className="screen-inner">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Icon name="users" size={30} />}
|
icon={<Icon name="users" size={30} />}
|
||||||
title="Welcome to Keynectr"
|
title="Welcome to Nostr Feed Manager"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
You haven't created a profile yet. A Nostr profile is your identity on the public
|
You haven't created a profile yet. A Nostr profile is your identity on the public
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export function SettingsScreen() {
|
||||||
<dl className="info-list">
|
<dl className="info-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Application</dt>
|
<dt>Application</dt>
|
||||||
<dd>Keynectr v{state?.version ?? '?'}</dd>
|
<dd>Nostr Feed Manager v{state?.version ?? '?'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Backend</dt>
|
<dt>Backend</dt>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
Keynectr — design system
|
Nostr Feed Manager — design system
|
||||||
Light/dark/system themes via `data-theme` on <html>.
|
Light/dark/system themes via `data-theme` on <html>.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ describe('App', () => {
|
||||||
const { user } = renderApp(backend);
|
const { user } = renderApp(backend);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('Welcome to Keynectr')).toBeInTheDocument();
|
expect(await screen.findByText('Welcome to Nostr Feed Manager')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
|
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ describe('HomeScreen', () => {
|
||||||
const { onCreateProfile } = renderHome(backend);
|
const { onCreateProfile } = renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
|
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
|
||||||
|
|
||||||
expect(await screen.findByText('Welcome to Keynectr')).toBeInTheDocument();
|
expect(await screen.findByText('Welcome to Nostr Feed Manager')).toBeInTheDocument();
|
||||||
await userEvent
|
await userEvent
|
||||||
.setup()
|
.setup()
|
||||||
.click(screen.getByRole('button', { name: /Create your first profile/i }));
|
.click(screen.getByRole('button', { name: /Create your first profile/i }));
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ describe('SettingsScreen', () => {
|
||||||
renderWithApp(<SettingsScreen />);
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('/home/user/.local/share/keynectr/profiles_vault.json'),
|
await screen.findByText('/home/user/.local/share/nost-feed-manager/profiles_vault.json'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument();
|
expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -61,7 +61,7 @@ describe('SettingsScreen', () => {
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
renderWithApp(<SettingsScreen />);
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument();
|
expect(await screen.findByText(/Nostr Feed Manager v/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument();
|
expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ export function makeState(overrides?: Partial<AppState>): AppState {
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
vault_path: '/home/user/.local/share/keynectr/profiles_vault.json',
|
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
|
||||||
settings_path: '/home/user/.local/share/keynectr/settings.json',
|
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
|
||||||
encrypted_storage: false,
|
encrypted_storage: false,
|
||||||
vault_locked: false,
|
vault_locked: false,
|
||||||
migrated_from: null,
|
migrated_from: null,
|
||||||
|
|
@ -188,7 +188,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
backupNow: vi.fn(async () => ({
|
backupNow: vi.fn(async () => ({
|
||||||
backup_path: '/home/user/.local/share/keynectr/profiles_vault.json.backup-1',
|
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
|
||||||
})),
|
})),
|
||||||
setVaultPassword: vi.fn(async () => ({
|
setVaultPassword: vi.fn(async () => ({
|
||||||
...state,
|
...state,
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ pub struct ReplyEnvelope {
|
||||||
|
|
||||||
/// Run the JSON-lines IPC server on stdin/stdout.
|
/// Run the JSON-lines IPC server on stdin/stdout.
|
||||||
///
|
///
|
||||||
/// The Electron main process spawns `keynectr serve` and
|
/// The Electron main process spawns `nostr-manager-backend serve` and
|
||||||
/// exchanges one JSON object per line. Requests are processed sequentially so
|
/// exchanges one JSON object per line. Requests are processed sequentially so
|
||||||
/// the shared state never sees concurrent mutations.
|
/// the shared state never sees concurrent mutations.
|
||||||
pub async fn serve() -> Result<(), AppError> {
|
pub async fn serve() -> Result<(), AppError> {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,4 @@ pub mod vault;
|
||||||
pub use errors::AppError;
|
pub use errors::AppError;
|
||||||
|
|
||||||
/// Stable application-data directory name.
|
/// Stable application-data directory name.
|
||||||
pub const APP_DIR_NAME: &str = "keynectr";
|
pub const APP_DIR_NAME: &str = "nost-feed-manager";
|
||||||
|
|
||||||
/// Previous application-data directory name, migrated automatically on first use.
|
|
||||||
pub const LEGACY_APP_DIR_NAME: &str = "nost-feed-manager";
|
|
||||||
|
|
|
||||||
52
src/main.rs
52
src/main.rs
|
|
@ -1,18 +1,18 @@
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use keynectr::app::App;
|
use nostr_manager_backend::app::App;
|
||||||
use keynectr::errors::{AppError, ErrorKind};
|
use nostr_manager_backend::errors::{AppError, ErrorKind};
|
||||||
use keynectr::ipc;
|
use nostr_manager_backend::ipc;
|
||||||
use keynectr::profiles::{self, ProfileSummary};
|
use nostr_manager_backend::profiles::{self, ProfileSummary};
|
||||||
use keynectr::publish;
|
use nostr_manager_backend::publish;
|
||||||
use keynectr::relays;
|
use nostr_manager_backend::relays;
|
||||||
use keynectr::settings::Theme;
|
use nostr_manager_backend::settings::Theme;
|
||||||
use keynectr::signer::Signer;
|
use nostr_manager_backend::signer::Signer;
|
||||||
use keynectr::vault::{self, StoredProfile, Vault};
|
use nostr_manager_backend::vault::{self, StoredProfile, Vault};
|
||||||
|
|
||||||
const USAGE: &str = "\
|
const USAGE: &str = "\
|
||||||
keynectr <command> [args...]
|
nostr-manager-backend <command> [args...]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
create <label> Create a new profile
|
create <label> Create a new profile
|
||||||
|
|
@ -133,7 +133,7 @@ fn cli_list() -> Result<String, AppError> {
|
||||||
fn cli_switch(args: &[String]) -> Result<String, AppError> {
|
fn cli_switch(args: &[String]) -> Result<String, AppError> {
|
||||||
let npub = args
|
let npub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
.ok_or_else(|| AppError::config("Usage: keynectr switch <npub>"))?;
|
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend switch <npub>"))?;
|
||||||
|
|
||||||
let mut app = App::load()?;
|
let mut app = App::load()?;
|
||||||
profiles::set_active(&mut app.vault, npub)?;
|
profiles::set_active(&mut app.vault, npub)?;
|
||||||
|
|
@ -143,7 +143,9 @@ fn cli_switch(args: &[String]) -> Result<String, AppError> {
|
||||||
|
|
||||||
async fn cli_publish(args: &[String]) -> Result<String, AppError> {
|
async fn cli_publish(args: &[String]) -> Result<String, AppError> {
|
||||||
if args.len() < 4 {
|
if args.len() < 4 {
|
||||||
return Err(AppError::config("Usage: keynectr publish <npub> <content>"));
|
return Err(AppError::config(
|
||||||
|
"Usage: nostr-manager-backend publish <npub> <content>",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let npub = &args[2];
|
let npub = &args[2];
|
||||||
|
|
@ -167,7 +169,9 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
|
||||||
|
|
||||||
fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
|
fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
|
||||||
let [_, _, npub, url] = args else {
|
let [_, _, npub, url] = args else {
|
||||||
return Err(AppError::config("Usage: keynectr set-picture <npub> <url>"));
|
return Err(AppError::config(
|
||||||
|
"Usage: nostr-manager-backend set-picture <npub> <url>",
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut app = load_app_with_unlock()?;
|
let mut app = load_app_with_unlock()?;
|
||||||
|
|
@ -190,7 +194,7 @@ fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
|
||||||
fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
|
fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
|
||||||
let npub = args
|
let npub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
.ok_or_else(|| AppError::config("Usage: keynectr publish-name <npub>"))?;
|
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend publish-name <npub>"))?;
|
||||||
|
|
||||||
let app = load_app_with_unlock()?;
|
let app = load_app_with_unlock()?;
|
||||||
let key = app.vault_key().copied();
|
let key = app.vault_key().copied();
|
||||||
|
|
@ -223,7 +227,7 @@ async fn cli_feed(args: &[String]) -> Result<String, AppError> {
|
||||||
let limit = rest
|
let limit = rest
|
||||||
.first()
|
.first()
|
||||||
.and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0))
|
.and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0))
|
||||||
.unwrap_or(keynectr::feed::DEFAULT_LIMIT);
|
.unwrap_or(nostr_manager_backend::feed::DEFAULT_LIMIT);
|
||||||
|
|
||||||
let app = App::load()?;
|
let app = App::load()?;
|
||||||
let items = if contacts {
|
let items = if contacts {
|
||||||
|
|
@ -232,10 +236,10 @@ async fn cli_feed(args: &[String]) -> Result<String, AppError> {
|
||||||
.active_profile
|
.active_profile
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(AppError::no_active_profile)?;
|
.ok_or_else(AppError::no_active_profile)?;
|
||||||
let pubkey = keynectr::feed::owner_pubkey(npub)?;
|
let pubkey = nostr_manager_backend::feed::owner_pubkey(npub)?;
|
||||||
keynectr::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await?
|
nostr_manager_backend::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await?
|
||||||
} else {
|
} else {
|
||||||
keynectr::feed::aggregate_feed(&app.settings, limit).await?
|
nostr_manager_backend::feed::aggregate_feed(&app.settings, limit).await?
|
||||||
};
|
};
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Ok(if contacts {
|
return Ok(if contacts {
|
||||||
|
|
@ -270,7 +274,9 @@ fn shorten_note(content: &str) -> String {
|
||||||
|
|
||||||
async fn cli_relays(args: &[String]) -> Result<String, AppError> {
|
async fn cli_relays(args: &[String]) -> Result<String, AppError> {
|
||||||
let sub = args.get(2).ok_or_else(|| {
|
let sub = args.get(2).ok_or_else(|| {
|
||||||
AppError::config("Usage: keynectr relays <list|add|remove|enable|disable|test> [...]")
|
AppError::config(
|
||||||
|
"Usage: nostr-manager-backend relays <list|add|remove|enable|disable|test> [...]",
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut app = App::load()?;
|
let mut app = App::load()?;
|
||||||
|
|
@ -326,7 +332,7 @@ async fn cli_relays(args: &[String]) -> Result<String, AppError> {
|
||||||
fn cli_settings(args: &[String]) -> Result<String, AppError> {
|
fn cli_settings(args: &[String]) -> Result<String, AppError> {
|
||||||
let sub = args
|
let sub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
.ok_or_else(|| AppError::config("Usage: keynectr settings <get|set>"))?;
|
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend settings <get|set>"))?;
|
||||||
|
|
||||||
let mut app = App::load()?;
|
let mut app = App::load()?;
|
||||||
|
|
||||||
|
|
@ -441,7 +447,7 @@ fn cli_unlock() -> Result<String, AppError> {
|
||||||
fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
|
fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
|
||||||
let npub = args
|
let npub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
.ok_or_else(|| AppError::config("Usage: keynectr show-secret <npub>"))?;
|
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend show-secret <npub>"))?;
|
||||||
|
|
||||||
let app = load_app_with_unlock()?;
|
let app = load_app_with_unlock()?;
|
||||||
let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?;
|
let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?;
|
||||||
|
|
@ -456,7 +462,7 @@ fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
|
||||||
async fn cli_signer(args: &[String]) -> Result<String, AppError> {
|
async fn cli_signer(args: &[String]) -> Result<String, AppError> {
|
||||||
let sub = args
|
let sub = args
|
||||||
.get(2)
|
.get(2)
|
||||||
.ok_or_else(|| AppError::config("Usage: keynectr signer <status|connect>"))?;
|
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend signer <status|connect>"))?;
|
||||||
|
|
||||||
match sub.as_str() {
|
match sub.as_str() {
|
||||||
"status" => {
|
"status" => {
|
||||||
|
|
@ -477,7 +483,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
|
||||||
),
|
),
|
||||||
"In the GUI, the signer listens as long as the app is running. From here, run:"
|
"In the GUI, the signer listens as long as the app is running. From here, run:"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
" keynectr signer connect <nostrconnect://…>".to_string(),
|
" nostr-manager-backend signer connect <nostrconnect://…>".to_string(),
|
||||||
"Sign/decrypt requests must be approved in the GUI signer screen.".to_string(),
|
"Sign/decrypt requests must be approved in the GUI signer screen.".to_string(),
|
||||||
]
|
]
|
||||||
.join("\n"))
|
.join("\n"))
|
||||||
|
|
|
||||||
194
src/main.rs.backup
Normal file
194
src/main.rs.backup
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct Profile {
|
||||||
|
label: String,
|
||||||
|
public_key: String,
|
||||||
|
secret_key: String, // Stored as a plaintext hex string
|
||||||
|
created_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const VAULT_FILE: &str = "profiles_vault.json";
|
||||||
|
|
||||||
|
fn load_vault() -> Result<Vec<Profile>, String> {
|
||||||
|
if !Path::new(VAULT_FILE).exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(VAULT_FILE)
|
||||||
|
.map_err(|e| format!("Failed to read {VAULT_FILE}: {e}"))?;
|
||||||
|
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&content)
|
||||||
|
.map_err(|e| format!("Failed to parse {VAULT_FILE}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_vault(profiles: &[Profile]) -> Result<(), String> {
|
||||||
|
let content = serde_json::to_string_pretty(profiles)
|
||||||
|
.map_err(|e| format!("Failed to serialize profiles: {e}"))?;
|
||||||
|
|
||||||
|
fs::write(VAULT_FILE, content)
|
||||||
|
.map_err(|e| format!("Failed to write {VAULT_FILE}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_timestamp() -> Result<u64, String> {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.map_err(|e| format!("System clock error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if let Err(error) = run().await {
|
||||||
|
eprintln!("Error: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), String> {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
|
let command = args.get(1).ok_or_else(|| {
|
||||||
|
[
|
||||||
|
"Usage: nostr-manager-backend <command> [args...]",
|
||||||
|
"Commands:",
|
||||||
|
" create <label>",
|
||||||
|
" list",
|
||||||
|
" switch <npub>",
|
||||||
|
" publish <npub> <content>",
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = match command.as_str() {
|
||||||
|
"create" => {
|
||||||
|
let label = args
|
||||||
|
.get(2)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "New Profile".to_string());
|
||||||
|
|
||||||
|
let keys = Keys::generate();
|
||||||
|
|
||||||
|
// Explicit ::hex prevents conflict with nostr_sdk::prelude::*.
|
||||||
|
let secret_hex =
|
||||||
|
::hex::encode(keys.secret_key().to_secret_bytes());
|
||||||
|
|
||||||
|
let public_key = keys
|
||||||
|
.public_key()
|
||||||
|
.to_bech32()
|
||||||
|
.map_err(|e| format!("Failed to encode public key: {e}"))?;
|
||||||
|
|
||||||
|
let profile = Profile {
|
||||||
|
label,
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
secret_key: secret_hex,
|
||||||
|
created_at: unix_timestamp()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut profiles = load_vault()?;
|
||||||
|
profiles.push(profile);
|
||||||
|
save_vault(&profiles)?;
|
||||||
|
|
||||||
|
format!("Created profile: {public_key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
"list" => {
|
||||||
|
let profiles = load_vault()?;
|
||||||
|
|
||||||
|
serde_json::to_string_pretty(&profiles)
|
||||||
|
.map_err(|e| format!("Failed to serialize profiles: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
"switch" => {
|
||||||
|
let npub = args
|
||||||
|
.get(2)
|
||||||
|
.ok_or("Usage: nostr-manager-backend switch <npub>")?;
|
||||||
|
|
||||||
|
let profiles = load_vault()?;
|
||||||
|
|
||||||
|
if !profiles.iter().any(|profile| profile.public_key == *npub) {
|
||||||
|
return Err(format!("No stored profile found for {npub}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("Switched context to: {npub}")
|
||||||
|
}
|
||||||
|
|
||||||
|
"publish" => {
|
||||||
|
if args.len() < 4 {
|
||||||
|
return Err(
|
||||||
|
"Usage: nostr-manager-backend publish <npub> <content>"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let npub = &args[2];
|
||||||
|
let content = args[3..].join(" ");
|
||||||
|
|
||||||
|
let profiles = load_vault()?;
|
||||||
|
|
||||||
|
let secret_hex = profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.public_key == *npub)
|
||||||
|
.map(|profile| profile.secret_key.clone())
|
||||||
|
.ok_or_else(|| format!("No stored profile found for {npub}"))?;
|
||||||
|
|
||||||
|
let secret_bytes = ::hex::decode(&secret_hex)
|
||||||
|
.map_err(|e| format!("Stored secret key is not valid hex: {e}"))?;
|
||||||
|
|
||||||
|
let secret_key = SecretKey::from_slice(&secret_bytes)
|
||||||
|
.map_err(|e| format!("Stored secret key is invalid: {e}"))?;
|
||||||
|
|
||||||
|
// Keys::from(secret_key) is incorrect for this nostr-sdk API.
|
||||||
|
let keys = Keys::new(secret_key);
|
||||||
|
|
||||||
|
let client = Client::new(keys.clone());
|
||||||
|
|
||||||
|
client
|
||||||
|
.add_relay("wss://relay.damus.io")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to add Damus relay: {e}"))?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.add_relay("wss://relay.nostr.band")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to add nostr.band relay: {e}"))?;
|
||||||
|
|
||||||
|
client.connect().await;
|
||||||
|
|
||||||
|
let builder = EventBuilder::new(Kind::TextNote, content);
|
||||||
|
|
||||||
|
let event = builder
|
||||||
|
.sign(&keys)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to sign event: {e}"))?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.send_event(&event)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to publish event: {e}"))?;
|
||||||
|
|
||||||
|
let event_id = event
|
||||||
|
.id
|
||||||
|
.to_bech32()
|
||||||
|
.map_err(|e| format!("Failed to encode event ID: {e}"))?;
|
||||||
|
|
||||||
|
format!("Published: {event_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
return Err(format!("Unknown command: {command}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{result}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
52
src/vault.rs
52
src/vault.rs
|
|
@ -3,7 +3,6 @@ use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Once;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
|
@ -11,6 +10,7 @@ use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
use crate::APP_DIR_NAME;
|
||||||
|
|
||||||
/// Current vault schema version.
|
/// Current vault schema version.
|
||||||
pub const VAULT_VERSION: u32 = 2;
|
pub const VAULT_VERSION: u32 = 2;
|
||||||
|
|
@ -114,57 +114,17 @@ pub fn unix_timestamp() -> Result<u64, AppError> {
|
||||||
/// Stable application-data directory for this app.
|
/// Stable application-data directory for this app.
|
||||||
///
|
///
|
||||||
/// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`.
|
/// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`.
|
||||||
///
|
|
||||||
/// The first call also migrates a previous-named data directory (from before
|
|
||||||
/// the app was renamed) by moving it to the new location, so existing vaults,
|
|
||||||
/// settings and backups are preserved without any user action.
|
|
||||||
pub fn data_dir() -> PathBuf {
|
pub fn data_dir() -> PathBuf {
|
||||||
let dir = if let Ok(dir) = env::var("XDG_DATA_HOME") {
|
if let Ok(dir) = env::var("XDG_DATA_HOME") {
|
||||||
if !dir.trim().is_empty() {
|
if !dir.trim().is_empty() {
|
||||||
PathBuf::from(dir).join(crate::APP_DIR_NAME)
|
return PathBuf::from(dir).join(APP_DIR_NAME);
|
||||||
} else {
|
|
||||||
default_app_dir()
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
default_app_dir()
|
|
||||||
};
|
|
||||||
static MIGRATED: Once = Once::new();
|
|
||||||
MIGRATED.call_once(|| migrate_legacy_app_dir(&dir));
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_app_dir() -> PathBuf {
|
|
||||||
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||||
PathBuf::from(home)
|
PathBuf::from(home)
|
||||||
.join(".local")
|
.join(".local")
|
||||||
.join("share")
|
.join("share")
|
||||||
.join(crate::APP_DIR_NAME)
|
.join(APP_DIR_NAME)
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the legacy application-data directory to the new name, once.
|
|
||||||
///
|
|
||||||
/// No-op when the target already exists or there is nothing to move; a failed
|
|
||||||
/// move is reported rather than silently losing the data.
|
|
||||||
fn migrate_legacy_app_dir(target: &Path) {
|
|
||||||
if target.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let legacy = match &target.parent() {
|
|
||||||
Some(parent) => parent.join(crate::LEGACY_APP_DIR_NAME),
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
if !legacy.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = fs::rename(&legacy, target) {
|
|
||||||
// Leave both in place: the app starts fresh at `target` and the old
|
|
||||||
// data stays untouched for manual recovery.
|
|
||||||
eprintln!(
|
|
||||||
"Warning: could not move {} to {}: {e}",
|
|
||||||
legacy.display(),
|
|
||||||
target.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn vault_path() -> PathBuf {
|
pub fn vault_path() -> PathBuf {
|
||||||
|
|
@ -442,7 +402,7 @@ mod tests {
|
||||||
|
|
||||||
fn temp_vault_path() -> PathBuf {
|
fn temp_vault_path() -> PathBuf {
|
||||||
let dir = env::temp_dir().join(format!(
|
let dir = env::temp_dir().join(format!(
|
||||||
"keynectr-test-{}-{}",
|
"nost-feed-manager-test-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
COUNTER.fetch_add(1, Ordering::SeqCst)
|
COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||||
));
|
));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue