Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd08273eb | ||
|
|
79a4dd387d | ||
|
|
ea9abae554 | ||
|
|
d1b83fdc33 | ||
|
|
4329385abe | ||
|
|
d926c1e3ea | ||
|
|
66f75ad105 | ||
|
|
5f2a5d7a37 | ||
|
|
319b1038d2 | ||
|
|
5500082426 | ||
|
|
7a34d66c7c | ||
|
|
57c85f5b99 | ||
|
|
bf7080654d | ||
|
|
42983383b0 | ||
|
|
e2b10c173e | ||
|
|
9e33da717b | ||
|
|
584ab34df6 | ||
|
|
0514c1d982 |
@@ -152,11 +152,23 @@ jobs:
|
|||||||
echo "Artifacts structure:"
|
echo "Artifacts structure:"
|
||||||
find artifacts -type f -exec ls -la {} \;
|
find artifacts -type f -exec ls -la {} \;
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: |
|
||||||
|
# One `<sha256> <path>` line per artifact. The in-app updater reads
|
||||||
|
# this to verify a download before installing it, so it must be
|
||||||
|
# published alongside every release. Written outside the directory
|
||||||
|
# being hashed so the checksums file never includes itself.
|
||||||
|
find artifacts -type f ! -name SHA256SUMS -print0 \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 sha256sum > SHA256SUMS.raw
|
||||||
|
mv SHA256SUMS.raw artifacts/SHA256SUMS
|
||||||
|
cat artifacts/SHA256SUMS
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
server_url: "https://git.reya.su/"
|
server_url: "https://git.reya.info/"
|
||||||
repository: "reya/coop"
|
repository: "reya/coop"
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Rust coding guidelines
|
||||||
|
|
||||||
|
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||||
|
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||||
|
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||||
|
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||||
|
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||||
|
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||||
|
- Propagate errors with `?` when the calling function should handle them
|
||||||
|
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||||
|
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||||
|
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||||
|
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||||
|
* Avoid creative additions unless explicitly requested
|
||||||
|
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||||
|
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||||
|
Example:
|
||||||
|
```rust
|
||||||
|
executor.spawn({
|
||||||
|
let task_ran = task_ran.clone();
|
||||||
|
async move {
|
||||||
|
*task_ran.borrow_mut() = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
# Timers in tests
|
||||||
|
|
||||||
|
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||||
|
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||||
|
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||||
|
|
||||||
|
# GPUI
|
||||||
|
|
||||||
|
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||||
|
|
||||||
|
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||||
|
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||||
|
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||||
|
|
||||||
|
## `Window`
|
||||||
|
|
||||||
|
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||||
|
|
||||||
|
* `thing.entity_id()` returns `EntityId`
|
||||||
|
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||||
|
* `thing.read(cx: &App)` returns `&T`.
|
||||||
|
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||||
|
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||||
|
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||||
|
|
||||||
|
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||||
|
|
||||||
|
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||||
|
|
||||||
|
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
All use of entities and UI rendering occurs on a single foreground thread.
|
||||||
|
|
||||||
|
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||||
|
|
||||||
|
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||||
|
|
||||||
|
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||||
|
|
||||||
|
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||||
|
|
||||||
|
* Awaiting the task in some other async context.
|
||||||
|
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||||
|
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||||
|
|
||||||
|
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
struct TextWithBorder(SharedString);
|
||||||
|
|
||||||
|
impl Render for TextWithBorder {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().border_1().child(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||||
|
|
||||||
|
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||||
|
|
||||||
|
The style methods on elements are similar to those used by Tailwind CSS.
|
||||||
|
|
||||||
|
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||||
|
|
||||||
|
## Input events
|
||||||
|
|
||||||
|
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||||
|
|
||||||
|
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||||
|
|
||||||
|
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||||
|
|
||||||
|
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||||
|
|
||||||
|
## Notify
|
||||||
|
|
||||||
|
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||||
|
|
||||||
|
## Entity events
|
||||||
|
|
||||||
|
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||||
|
|
||||||
|
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||||
|
|
||||||
|
# Pull request hygiene
|
||||||
|
|
||||||
|
When an agent opens or updates a pull request, it must:
|
||||||
|
|
||||||
|
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
||||||
|
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||||
|
- Avoid trailing punctuation in PR titles.
|
||||||
|
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
||||||
|
- Include a `Release Notes:` section as the final section in the PR body.
|
||||||
|
- Use one bullet under `Release Notes:`:
|
||||||
|
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||||
|
- `- N/A` for docs-only and other non-user-facing changes.
|
||||||
|
- Format release notes exactly with a blank line after the heading, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Release Notes:
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
```
|
||||||
Generated
+422
-301
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0"
|
version = "1.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -27,6 +27,14 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
|
# Crypto (NIP-17 encrypted file messages)
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
data-encoding = "2"
|
||||||
|
hkdf = "0.12"
|
||||||
|
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
||||||
|
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] }
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
@@ -45,6 +53,7 @@ webbrowser = "1.0.4"
|
|||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
|
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# Use stacker's psm version which may have better WASM support
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
@@ -2,7 +2,7 @@
|
|||||||
"id": "aurora",
|
"id": "aurora",
|
||||||
"name": "Aurora",
|
"name": "Aurora",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://github.com/lumehq/coop",
|
"url": "https://coopchat.xyz",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#fdfcfeff",
|
"background": "#fdfcfeff",
|
||||||
"surface_background": "#f8f8ffff",
|
"surface_background": "#f8f8ffff",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "forest",
|
"id": "forest",
|
||||||
"name": "Forest",
|
"name": "Forest",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://github.com/lumehq/coop",
|
"url": "https://coopchat.xyz",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#fbfefcff",
|
"background": "#fbfefcff",
|
||||||
"surface_background": "#f4fbf6ff",
|
"surface_background": "#f4fbf6ff",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "ocean",
|
"id": "ocean",
|
||||||
"name": "Ocean",
|
"name": "Ocean",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://github.com/lumehq/coop",
|
"url": "https://coopchat.xyz",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#fafefeff",
|
"background": "#fafefeff",
|
||||||
"surface_background": "#f2fbfaff",
|
"surface_background": "#f2fbfaff",
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
ureq.workspace = true
|
||||||
|
|
||||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
|
||||||
|
|||||||
+259
-113
@@ -1,62 +1,99 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
use std::sync::Arc;
|
||||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use instant::{Duration, Instant};
|
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||||
|
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
|
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||||
|
|
||||||
|
mod source;
|
||||||
|
|
||||||
|
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
|
||||||
|
|
||||||
|
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
|
||||||
|
const GITEA_REPO_OWNER: &str = "reya";
|
||||||
|
const GITEA_REPO_NAME: &str = "coop";
|
||||||
|
|
||||||
|
/// Delay before the automatic check that runs on startup.
|
||||||
|
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
|
||||||
|
/// How long a failure stays visible before the status reverts to "Up to date".
|
||||||
|
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
|
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
||||||
|
|
||||||
fn get_github_repo_owner() -> String {
|
fn uses_managed_updates() -> bool {
|
||||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
||||||
}
|
std::env::var("FLATPAK_ID").is_ok()
|
||||||
|
// Allow opting out of in-app updates via an explicit environment variable.
|
||||||
fn get_github_repo_name() -> String {
|
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||||
}
|
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
||||||
|
|
||||||
fn is_flatpak_installation() -> bool {
|
|
||||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the auto-update system.
|
/// Initialize the auto-update system.
|
||||||
///
|
|
||||||
/// Skips initialization when running as a Flatpak (updates are handled by the
|
|
||||||
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
|
|
||||||
/// entity and schedules a check for updates after a 2-minute delay.
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
if is_flatpak_installation() {
|
if uses_managed_updates() {
|
||||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
log::info!(
|
||||||
|
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
|
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
|
||||||
|
|
||||||
|
let Some(filter) = asset_filter_for(os, arch) else {
|
||||||
|
log::info!(
|
||||||
|
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
|
||||||
|
log::error!(
|
||||||
|
"Skipping auto-update initialization: crate version {:?} is not valid semver",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoUpdater::set_global(
|
||||||
|
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
|
||||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
|
||||||
|
|
||||||
/// The global auto-updater entity.
|
|
||||||
///
|
|
||||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
|
||||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
|
||||||
///
|
|
||||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
/// The blocking engine, driven on the background executor.
|
||||||
pub updater: Entity<Updater>,
|
engine: Arc<UpdateEngine<GiteaSource>>,
|
||||||
|
status: UpdateStatus,
|
||||||
|
/// The newer release found by the last successful check, if any.
|
||||||
|
available: Option<Release>,
|
||||||
/// Currently running app version.
|
/// Currently running app version.
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
/// Keeps the observer subscription alive.
|
/// The in-flight check or download, if any.
|
||||||
_subscription: Subscription,
|
task: Option<Task<()>>,
|
||||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
|
||||||
error_time: Option<Instant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
|
/// Whether auto-update is available for this installation.
|
||||||
|
pub fn is_available(cx: &App) -> bool {
|
||||||
|
cx.try_global::<GlobalAutoUpdater>().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the global auto updater instance, if one was initialized.
|
||||||
|
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
||||||
|
cx.try_global::<GlobalAutoUpdater>()
|
||||||
|
.map(|global| global.0.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance.
|
/// Retrieve the global auto updater instance.
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
@@ -66,92 +103,49 @@ impl AutoUpdater {
|
|||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
window: &mut Window,
|
||||||
|
version: Version,
|
||||||
|
filter: AssetFilter,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||||
|
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||||
|
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||||
|
|
||||||
let repo_owner = get_github_repo_owner();
|
// Schedule an auto-check after a 2-minute delay
|
||||||
let repo_name = get_github_repo_name();
|
|
||||||
|
|
||||||
let source =
|
|
||||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
|
||||||
"macos" => "macos",
|
|
||||||
"linux" => "linux",
|
|
||||||
_ => "",
|
|
||||||
});
|
|
||||||
|
|
||||||
let updater: Entity<Updater> =
|
|
||||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
|
||||||
|
|
||||||
// When an update becomes available, automatically download and install it.
|
|
||||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
|
||||||
let status = this.updater.read(cx).status().clone();
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Available(_)) {
|
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.download_and_install(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Errored(_)) {
|
|
||||||
this.error_time = Some(Instant::now());
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
|
||||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
} else {
|
|
||||||
this.error_time = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
|
||||||
// end of the current frame so the window is fully set up).
|
|
||||||
cx.defer_in(window, |_this, _window, cx| {
|
cx.defer_in(window, |_this, _window, cx| {
|
||||||
let duration = Duration::from_secs(120);
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(duration).await;
|
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
updater,
|
engine,
|
||||||
|
status: UpdateStatus::Idle,
|
||||||
|
available: None,
|
||||||
version,
|
version,
|
||||||
_subscription: subscription,
|
task: None,
|
||||||
error_time: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn idle(&self, cx: &App) -> bool {
|
/// Whether nothing is happening, so the UI can hide the status line.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn idle(&self) -> bool {
|
||||||
if status == &UpdateStatus::Idle {
|
matches!(self.status, UpdateStatus::Idle)
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if matches!(status, UpdateStatus::Errored(_))
|
|
||||||
&& self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self, cx: &App) -> SharedString {
|
/// Whether a verified update is installed and waiting for a restart.
|
||||||
let status = self.updater.read(cx).status();
|
pub fn staged(&self) -> bool {
|
||||||
|
matches!(self.status, UpdateStatus::Staged(_))
|
||||||
|
}
|
||||||
|
|
||||||
match status {
|
/// A short, human-readable description of the current status.
|
||||||
UpdateStatus::Idle => "Up to date".into(),
|
pub fn status(&self) -> SharedString {
|
||||||
|
match &self.status {
|
||||||
|
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||||
UpdateStatus::UpToDate => "Up to date".into(),
|
|
||||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||||
UpdateStatus::Downloading { downloaded, total } => {
|
UpdateStatus::Downloading { downloaded, total } => {
|
||||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||||
@@ -165,16 +159,168 @@ impl AutoUpdater {
|
|||||||
UpdateStatus::Staged(version) => {
|
UpdateStatus::Staged(version) => {
|
||||||
format!("Version {version} ready — restart to apply").into()
|
format!("Version {version} ready — restart to apply").into()
|
||||||
}
|
}
|
||||||
UpdateStatus::Errored(msg) => {
|
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
|
||||||
if self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
"Up to date".into()
|
|
||||||
} else {
|
|
||||||
format!("Update failed: {msg}").into()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check the release host for a newer version, then download and install it.
|
||||||
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_status(UpdateStatus::Checking, cx);
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_executor()
|
||||||
|
.spawn(async move { engine.check() })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match result {
|
||||||
|
Ok(Some(release)) => {
|
||||||
|
log::info!("Update {} is available", release.version);
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.available = Some(release);
|
||||||
|
this.set_status(UpdateStatus::Available(version), cx);
|
||||||
|
this.download_and_install(cx);
|
||||||
|
}
|
||||||
|
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update check failed: {error}");
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the available update, verify it, and swap it into place.
|
||||||
|
fn download_and_install(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.status.is_busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(release) = self.available.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
self.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: 0,
|
||||||
|
total: None,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.task = Some(cx.spawn(async move |this, cx| {
|
||||||
|
let downloaded = Arc::new(AtomicU64::new(0));
|
||||||
|
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let download_task = {
|
||||||
|
let (engine, release) = (engine.clone(), release.clone());
|
||||||
|
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
|
||||||
|
cx.background_executor().spawn(async move {
|
||||||
|
let result = engine.download(&release, |got, expected| {
|
||||||
|
downloaded.store(got, Ordering::Relaxed);
|
||||||
|
total.store(expected.unwrap_or(0), Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let got = downloaded.load(Ordering::Relaxed);
|
||||||
|
let total = total.load(Ordering::Relaxed);
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(
|
||||||
|
UpdateStatus::Downloading {
|
||||||
|
downloaded: got,
|
||||||
|
total: (total != 0).then_some(total),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
if done.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(120))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact = match download_task.await {
|
||||||
|
Ok(artifact) => artifact,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Update download failed: {error}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
|
||||||
|
|
||||||
|
let installed = {
|
||||||
|
let engine = engine.clone();
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { engine.install(&artifact) })
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.task = None;
|
||||||
|
match installed {
|
||||||
|
Ok(installed) => {
|
||||||
|
if let Some(path) = installed.restart_path {
|
||||||
|
cx.set_restart_path(path);
|
||||||
|
}
|
||||||
|
let version = release.version.clone();
|
||||||
|
this.set_status(UpdateStatus::Staged(version), cx);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relaunch into the staged update.
|
||||||
|
pub fn restart(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.staged() {
|
||||||
|
log::warn!("Ignoring restart request: no update is staged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cx.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
|
||||||
|
let errored = matches!(status, UpdateStatus::Errored(_));
|
||||||
|
self.status = status;
|
||||||
|
|
||||||
|
if errored {
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(UpdateStatus::Idle, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||||
|
const RELEASE_PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
/// Which published artifact belongs to a target platform.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AssetFilter {
|
||||||
|
extension: &'static str,
|
||||||
|
arch: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetFilter {
|
||||||
|
/// Whether `name` is the installable artifact for this target.
|
||||||
|
fn matches(&self, name: &str) -> bool {
|
||||||
|
let name = name.to_ascii_lowercase();
|
||||||
|
name.ends_with(self.extension) && name.contains(self.arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||||
|
let extension = match os {
|
||||||
|
"macos" => ".dmg",
|
||||||
|
"linux" => ".tar.gz",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let arch = match (os, arch) {
|
||||||
|
// cargo-packager names the disk images `aarch64`/`x64`.
|
||||||
|
("macos", "aarch64") => "aarch64",
|
||||||
|
("macos", "x86_64") => "x64",
|
||||||
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
|
("linux", "aarch64") => "aarch64",
|
||||||
|
("linux", "x86_64") => "x86_64",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(AssetFilter { extension, arch })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads releases from a Gitea repository's Releases.
|
||||||
|
pub struct GiteaSource {
|
||||||
|
api_base: String,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
filter: AssetFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaSource {
|
||||||
|
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
|
||||||
|
/// (e.g. `https://git.reya.info/api/v1`).
|
||||||
|
pub fn new(
|
||||||
|
api_base: impl Into<String>,
|
||||||
|
owner: impl Into<String>,
|
||||||
|
repo: impl Into<String>,
|
||||||
|
filter: AssetFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||||
|
owner: owner.into(),
|
||||||
|
repo: repo.into(),
|
||||||
|
filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn releases_url(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
|
||||||
|
self.api_base, self.owner, self.repo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSource for GiteaSource {
|
||||||
|
fn fetch_latest(&self) -> Result<Release> {
|
||||||
|
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
|
||||||
|
let release = newest_published(&releases)
|
||||||
|
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| self.filter.matches(&asset.name))
|
||||||
|
.ok_or(Error::NoMatchingAsset {
|
||||||
|
target_os: std::env::consts::OS,
|
||||||
|
target_arch: std::env::consts::ARCH,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve the published checksum so the engine can reject a truncated or substituted download.
|
||||||
|
let sha256 = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
||||||
|
.map(|sums| http::get_string(&sums.browser_download_url))
|
||||||
|
.transpose()?
|
||||||
|
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||||
|
|
||||||
|
Ok(Release {
|
||||||
|
version: parse_tag(&release.tag_name)?,
|
||||||
|
notes: release
|
||||||
|
.body
|
||||||
|
.clone()
|
||||||
|
.filter(|body| !body.trim().is_empty())
|
||||||
|
.or_else(|| release.name.clone()),
|
||||||
|
asset: Asset {
|
||||||
|
name: asset.name.clone(),
|
||||||
|
url: asset.browser_download_url.clone(),
|
||||||
|
size: asset.size,
|
||||||
|
},
|
||||||
|
signature: None,
|
||||||
|
signature_url: None,
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.filter(|release| !release.draft && !release.prerelease)
|
||||||
|
.filter_map(|release| {
|
||||||
|
parse_tag(&release.tag_name)
|
||||||
|
.ok()
|
||||||
|
.map(|version| (version, release))
|
||||||
|
})
|
||||||
|
.max_by(|(left, _), (right, _)| left.cmp(right))
|
||||||
|
.map(|(_, release)| release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
|
||||||
|
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
|
||||||
|
sums.lines().find_map(|line| {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let (hash, path) = (parts.next()?, parts.next()?);
|
||||||
|
let path = path.strip_prefix('*').unwrap_or(path);
|
||||||
|
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||||
|
(base == asset_name).then(|| hash.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaRelease {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<GiteaAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release asset as returned by the Gitea API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking HTTP helpers for release metadata.
|
||||||
|
mod http {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui_updater_core::{Error, Result};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use ureq::Agent;
|
||||||
|
use ureq::tls::{RootCerts, TlsConfig};
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn agent() -> Agent {
|
||||||
|
Agent::config_builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.tls_config(
|
||||||
|
TlsConfig::builder()
|
||||||
|
.root_certs(RootCerts::PlatformVerifier)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.timeout_resolve(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||||
|
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut response = agent().get(url).call().map_err(|error| match error {
|
||||||
|
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
|
||||||
|
other => Error::Http(other.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
|
||||||
|
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_string(url: &str) -> Result<String> {
|
||||||
|
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui_updater_core::Version;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PUBLISHED: &[&str] = &[
|
||||||
|
"Coop_1.0.1_aarch64.dmg",
|
||||||
|
"Coop_1.0.1_x64.dmg",
|
||||||
|
"coop-linux-aarch64.tar.gz",
|
||||||
|
"coop-linux-x86_64.tar.gz",
|
||||||
|
"coop_1.0.1_aarch64.snap",
|
||||||
|
"coop_1.0.1_arm64-setup.exe",
|
||||||
|
"coop_1.0.1_x64-setup.exe",
|
||||||
|
"coop_1.0.1_x86_64.snap",
|
||||||
|
"su.reya.coop_aarch64.flatpak",
|
||||||
|
"su.reya.coop_x86_64.flatpak",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn selected(os: &str, arch: &str) -> Option<&'static str> {
|
||||||
|
let filter = asset_filter_for(os, arch)?;
|
||||||
|
PUBLISHED.iter().copied().find(|name| filter.matches(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
|
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||||
|
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "aarch64"),
|
||||||
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selected("linux", "x86_64"),
|
||||||
|
Some("coop-linux-x86_64.tar.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
|
||||||
|
let macos = asset_filter_for("macos", "aarch64").unwrap();
|
||||||
|
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
|
||||||
|
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
|
||||||
|
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
|
||||||
|
|
||||||
|
let linux = asset_filter_for("linux", "x86_64").unwrap();
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
|
||||||
|
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_checksums_by_basename_ignoring_directory_prefix() {
|
||||||
|
let sums = "\
|
||||||
|
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
||||||
|
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
|
||||||
|
789abc SHA256SUMS
|
||||||
|
";
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
|
||||||
|
Some("abcdef")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
|
||||||
|
Some("123456")
|
||||||
|
);
|
||||||
|
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
|
||||||
|
let releases: Vec<GiteaRelease> = serde_json::from_str(
|
||||||
|
r#"[
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.2",
|
||||||
|
"draft": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v2.0.0-rc.1",
|
||||||
|
"prerelease": true,
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "nightly",
|
||||||
|
"assets": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag_name": "v1.0.1",
|
||||||
|
"name": "v1.0.1",
|
||||||
|
"body": "Fixed app panic on flatpak installations",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "coop-linux-x86_64.tar.gz",
|
||||||
|
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
|
||||||
|
"size": 26160329
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let newest = newest_published(&releases).unwrap();
|
||||||
|
assert_eq!(newest.tag_name, "v1.0.1");
|
||||||
|
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
|
||||||
|
assert_eq!(newest.assets.len(), 1);
|
||||||
|
assert_eq!(newest.assets[0].size, 26160329);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires network access to the release host"]
|
||||||
|
fn live_release_source_resolves_the_running_platform() {
|
||||||
|
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||||
|
.expect("this platform should be supported");
|
||||||
|
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
|
||||||
|
|
||||||
|
let release = source
|
||||||
|
.fetch_latest()
|
||||||
|
.expect("release lookup should succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
release.version >= Version::new(1, 0, 0),
|
||||||
|
"unexpected version {}",
|
||||||
|
release.version
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.filter.matches(&release.asset.name),
|
||||||
|
"unexpected artifact {}",
|
||||||
|
release.asset.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
release.asset.url.starts_with("https://"),
|
||||||
|
"{} ",
|
||||||
|
release.asset.url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ mod room;
|
|||||||
|
|
||||||
pub use message::*;
|
pub use message::*;
|
||||||
pub use room::*;
|
pub use room::*;
|
||||||
|
pub use state::FileAttachment;
|
||||||
|
|
||||||
/// A static keypair used only for signing locally-cached rumor events.
|
/// A static keypair used only for signing locally-cached rumor events.
|
||||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||||
@@ -629,7 +630,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Load all rooms from the database.
|
/// Load all rooms from the database.
|
||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.get_rooms_task(cx);
|
let task = self.query_chat_rooms(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
@@ -650,8 +651,8 @@ impl ChatRegistry {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to load rooms from the database
|
/// Query the chat rooms from the database
|
||||||
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
@@ -677,7 +678,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::LOWERCASE_K, "14");
|
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
@@ -719,8 +720,8 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Parse a nostr event into a message and push it to the belonging room
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
///
|
///
|
||||||
/// If the room doesn't exist, it will be created.
|
/// - If the room doesn't exist, it will be created.
|
||||||
/// Updates room ordering based on the most recent messages.
|
/// - Updates room ordering based on the most recent messages.
|
||||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
@@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
|||||||
Tag::identifier(id),
|
Tag::identifier(id),
|
||||||
Tag::public_key(rumor.pubkey),
|
Tag::public_key(rumor.pubkey),
|
||||||
Tag::custom("r", [room_id]),
|
Tag::custom("r", [room_id]),
|
||||||
Tag::custom("k", ["14"]),
|
Tag::custom("k", [rumor.kind.to_string()]),
|
||||||
];
|
];
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||||
|
|||||||
+79
-36
@@ -4,6 +4,9 @@ use std::ops::Range;
|
|||||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||||
use gpui::{SharedString, SharedUri};
|
use gpui::{SharedString, SharedUri};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::FileAttachment;
|
||||||
|
|
||||||
|
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||||
|
|
||||||
/// Rendered message.
|
/// Rendered message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -21,61 +24,90 @@ pub struct Message {
|
|||||||
pub mentions: Vec<Mention>,
|
pub mentions: Vec<Mention>,
|
||||||
/// List of event of the message this message is a reply to
|
/// List of event of the message this message is a reply to
|
||||||
pub replies_to: Vec<EventId>,
|
pub replies_to: Vec<EventId>,
|
||||||
|
/// Encrypted file attachment
|
||||||
|
pub file: Option<FileAttachment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Event> for Message {
|
impl From<&Event> for Message {
|
||||||
fn from(val: &Event) -> Self {
|
fn from(val: &Event) -> Self {
|
||||||
let mentions = extract_mentions(&val.content);
|
from_parts(
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
val.id,
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
val.pubkey,
|
||||||
|
val.created_at,
|
||||||
Self {
|
val.kind,
|
||||||
id: val.id,
|
&val.content,
|
||||||
author: val.pubkey,
|
&val.tags,
|
||||||
content: string,
|
)
|
||||||
media,
|
|
||||||
created_at: val.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for Message {
|
impl From<&UnsignedEvent> for Message {
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
let mentions = extract_mentions(&val.content);
|
from_parts(
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
// Event ID must be known
|
||||||
id: val.id.unwrap(),
|
val.id.unwrap(),
|
||||||
author: val.pubkey,
|
val.pubkey,
|
||||||
content: string,
|
val.created_at,
|
||||||
media,
|
val.kind,
|
||||||
created_at: val.created_at,
|
&val.content,
|
||||||
mentions,
|
&val.tags,
|
||||||
replies_to,
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&NewMessage> for Message {
|
impl From<&NewMessage> for Message {
|
||||||
fn from(val: &NewMessage) -> Self {
|
fn from(val: &NewMessage) -> Self {
|
||||||
let mentions = extract_mentions(&val.rumor.content);
|
from_parts(
|
||||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
|
||||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
// Event ID must be known
|
||||||
id: val.rumor.id.unwrap(),
|
val.rumor.id.unwrap(),
|
||||||
author: val.rumor.pubkey,
|
val.rumor.pubkey,
|
||||||
content: string,
|
val.rumor.created_at,
|
||||||
|
val.rumor.kind,
|
||||||
|
&val.rumor.content,
|
||||||
|
&val.rumor.tags,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(
|
||||||
|
id: EventId,
|
||||||
|
author: PublicKey,
|
||||||
|
created_at: Timestamp,
|
||||||
|
kind: Kind,
|
||||||
|
content: &str,
|
||||||
|
tags: &Tags,
|
||||||
|
) -> Message {
|
||||||
|
let file = if kind == KIND_FILE_MESSAGE {
|
||||||
|
FileAttachment::from_tags(content, tags)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let has_file = file.is_some();
|
||||||
|
|
||||||
|
let replies_to = extract_reply_ids(tags);
|
||||||
|
|
||||||
|
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||||
|
let mentions = if has_file {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
extract_mentions(content)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (media, content) = if has_file {
|
||||||
|
(Vec::new(), String::new())
|
||||||
|
} else {
|
||||||
|
extract_and_remove_media_urls(content)
|
||||||
|
};
|
||||||
|
|
||||||
|
Message {
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
content,
|
||||||
media,
|
media,
|
||||||
created_at: val.rumor.created_at,
|
created_at,
|
||||||
mentions,
|
mentions,
|
||||||
replies_to,
|
replies_to,
|
||||||
}
|
file,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +137,17 @@ impl Hash for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
/// Single-line representation for reply previews, notifications and copy.
|
||||||
|
pub fn preview(&self) -> SharedString {
|
||||||
|
if let Some(file) = &self.file {
|
||||||
|
return format!("[File] {}", file.display_name()).into();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content.clone().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
|
|||||||
+46
-15
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
|
|||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||||
|
|
||||||
use crate::NewMessage;
|
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||||
|
|
||||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||||
@@ -439,12 +439,52 @@ impl Room {
|
|||||||
let content: String = content.into();
|
let content: String = content.into();
|
||||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
// Get current user's public key
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let sender = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
|
// Construct a direct message rumor event
|
||||||
|
// WARNING: never sign and send this event to relays
|
||||||
|
let mut event = EventBuilder::new(kind, content)
|
||||||
|
.tags(self.conversation_tags(&replies, sender, cx))
|
||||||
|
.finalize_unsigned(sender);
|
||||||
|
|
||||||
|
// Ensure that the ID is set
|
||||||
|
event.ensure_id();
|
||||||
|
|
||||||
|
Some(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
|
||||||
|
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = EventId>,
|
||||||
|
{
|
||||||
|
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||||
|
|
||||||
// Get current user's public key
|
// Get current user's public key
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
let sender = nostr.read(cx).current_user()?;
|
let sender = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
|
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||||
|
tags.extend(file.tags());
|
||||||
|
|
||||||
|
// Construct a file message rumor event
|
||||||
|
// WARNING: never sign and send this event to relays
|
||||||
|
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_unsigned(sender);
|
||||||
|
|
||||||
|
// Ensure that the ID is set
|
||||||
|
event.ensure_id();
|
||||||
|
|
||||||
|
Some(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||||
|
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
// Construct event's tags
|
// Construct event's tags
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
|
|
||||||
@@ -454,8 +494,8 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add all reply tags
|
// Add all reply tags
|
||||||
for id in replies.into_iter() {
|
for id in replies {
|
||||||
tags.push(Tag::event(id))
|
tags.push(Tag::event(*id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all receiver tags (no intermediate allocation)
|
// Add all receiver tags (no intermediate allocation)
|
||||||
@@ -467,16 +507,7 @@ impl Room {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
tags
|
||||||
// WARNING: never sign and send this event to relays
|
|
||||||
let mut event = EventBuilder::new(kind, content)
|
|
||||||
.tags(tags)
|
|
||||||
.finalize_unsigned(sender);
|
|
||||||
|
|
||||||
// Ensure that the ID is set
|
|
||||||
event.ensure_id();
|
|
||||||
|
|
||||||
Some(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the appropriate signer based on signer kind and available keys.
|
/// Select the appropriate signer based on signer kind and available keys.
|
||||||
@@ -609,7 +640,7 @@ async fn send_gift_wrap(
|
|||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
config: &SignerKind,
|
config: &SignerKind,
|
||||||
) -> Result<SendReport, Error> {
|
) -> Result<SendReport, Error> {
|
||||||
let k_tag = Tag::custom("k", vec!["14"]);
|
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||||
let mut extra_tags = vec![k_tag];
|
let mut extra_tags = vec![k_tag];
|
||||||
|
|
||||||
// Determine the receiver public key based on the config
|
// Determine the receiver public key based on the config
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chat::FileAttachment;
|
||||||
|
use gpui::SharedString;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
/// A file attachment that has been uploaded, but not sent yet.
|
||||||
|
///
|
||||||
|
/// The local `path` is kept around so the composer can preview
|
||||||
|
/// the file without downloading and decrypting it again.
|
||||||
|
pub(crate) struct PendingFile {
|
||||||
|
pub file: FileAttachment,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the encrypted file attachment of a message
|
||||||
|
pub(crate) enum DecryptedFile {
|
||||||
|
Loading,
|
||||||
|
Ready(PathBuf),
|
||||||
|
Failed(SharedString),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an upload, either plain or encrypted
|
||||||
|
pub(crate) enum Uploaded {
|
||||||
|
Url(Url),
|
||||||
|
File(FileAttachment, PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
||||||
|
pub(crate) fn file_url(path: &Path) -> String {
|
||||||
|
format!("file://{}", path.display())
|
||||||
|
}
|
||||||
+439
-48
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::Error;
|
||||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||||
use common::{TimestampExt, coop_cache};
|
use common::TimestampExt;
|
||||||
use futures::lock::Mutex;
|
use futures::lock::Mutex;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -12,8 +13,8 @@ use gpui::{
|
|||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||||
white,
|
retain_all, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, upload};
|
use state::{
|
||||||
|
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||||
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -30,11 +33,13 @@ use ui::input::{Input, InputEvent, InputState};
|
|||||||
use ui::menu::DropdownMenu;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
|
use ui::tooltip::Tooltip;
|
||||||
use ui::{
|
use ui::{
|
||||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||||
h_flex, v_flex,
|
h_flex, v_flex,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::file::*;
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
@@ -46,6 +51,7 @@ static EMOJI_RE: LazyLock<Regex> =
|
|||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
|
mod file;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
@@ -96,6 +102,12 @@ pub struct ChatPanel {
|
|||||||
/// Media Attachment
|
/// Media Attachment
|
||||||
attachments: Entity<Vec<Url>>,
|
attachments: Entity<Vec<Url>>,
|
||||||
|
|
||||||
|
/// Uploaded, encrypted file attachments which are not sent yet
|
||||||
|
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||||
|
|
||||||
|
/// Decrypted attachments of file messages, by message id
|
||||||
|
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||||
|
|
||||||
/// Upload state
|
/// Upload state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
|
||||||
@@ -110,6 +122,7 @@ impl ChatPanel {
|
|||||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
// Define attachments and replies_to entities
|
// Define attachments and replies_to entities
|
||||||
let attachments = cx.new(|_| vec![]);
|
let attachments = cx.new(|_| vec![]);
|
||||||
|
let encrypted_attachments = cx.new(|_| vec![]);
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
|
||||||
@@ -185,6 +198,8 @@ impl ChatPanel {
|
|||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
attachments,
|
attachments,
|
||||||
|
encrypted_attachments,
|
||||||
|
decrypted_files: HashMap::new(),
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
reports_by_id,
|
reports_by_id,
|
||||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -370,21 +385,32 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Get the message which includes all attachments
|
// Get the message which includes all plain attachments
|
||||||
let content = self.get_input_value(cx);
|
let content = self.get_input_value(cx);
|
||||||
|
|
||||||
// Get the replies to this message
|
// Get the replies to this message
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Return if message is empty
|
// Uploaded files are sent as encrypted file messages
|
||||||
if content.trim().is_empty() {
|
let files: Vec<FileAttachment> = self
|
||||||
|
.encrypted_attachments
|
||||||
|
.read(cx)
|
||||||
|
.iter()
|
||||||
|
.map(|pending| pending.file.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Return if there is nothing to send
|
||||||
|
if content.trim().is_empty() && files.is_empty() {
|
||||||
window.push_notification("Cannot send an empty message", cx);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If replying to exactly one message with only a valid emoji,
|
// If replying to exactly one message with only a valid emoji,
|
||||||
// send as a reaction instead of a text message
|
// send as a reaction instead of a text message
|
||||||
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
|
if replies.len() == 1
|
||||||
|
&& EMOJI_RE.is_match(&content)
|
||||||
|
&& self.attachments.read(cx).is_empty()
|
||||||
|
&& files.is_empty()
|
||||||
{
|
{
|
||||||
for reply in &replies {
|
for reply in &replies {
|
||||||
self.send_reaction(&content, reply, window, cx);
|
self.send_reaction(&content, reply, window, cx);
|
||||||
@@ -393,7 +419,15 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send_message(&content, replies, false, window, cx);
|
// Send the text part, including the plain attachment urls
|
||||||
|
if !content.trim().is_empty() {
|
||||||
|
self.send_message(&content, replies.clone(), false, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send every file as its own encrypted file message
|
||||||
|
for file in files {
|
||||||
|
self.send_file(file, replies.clone(), window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_reaction(
|
fn send_reaction(
|
||||||
@@ -426,29 +460,59 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let room = self.room.clone();
|
|
||||||
let content = value.to_string();
|
|
||||||
let sent_ids = self.sent_ids.clone();
|
|
||||||
|
|
||||||
// Upgrade room and create rumor + send task in a single read lock
|
// Upgrade room and create rumor + send task in a single read lock
|
||||||
let Some(room_entity) = room.upgrade() else {
|
let Some(room) = self.room.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create rumor and send task
|
let outcome = room.read_with(cx, |room, cx| {
|
||||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
let rumor = room.rumor(value, replies, reaction, cx)?;
|
||||||
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
let send_task = room.send(rumor.clone(), cx)?;
|
||||||
|
|
||||||
Some((rumor, send_task))
|
Some((rumor, send_task))
|
||||||
}) {
|
});
|
||||||
Some(pair) => pair,
|
|
||||||
None => {
|
match outcome {
|
||||||
window.push_notification("Failed to create message", cx);
|
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
||||||
return;
|
None => window.push_notification("Failed to create message", cx),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
|
||||||
|
fn send_file(
|
||||||
|
&mut self,
|
||||||
|
file: FileAttachment,
|
||||||
|
replies: Vec<EventId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Some(room) = self.room.upgrade() else {
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let outcome = room.read_with(cx, |room, cx| {
|
||||||
|
let rumor = room.file_rumor(file, replies, cx)?;
|
||||||
|
let send_task = room.send(rumor.clone(), cx)?;
|
||||||
|
|
||||||
|
Some((rumor, send_task))
|
||||||
|
});
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
||||||
|
None => window.push_notification("Failed to create message", cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a rumor optimistically and track the send reports of its gift wraps
|
||||||
|
fn dispatch(
|
||||||
|
&mut self,
|
||||||
|
rumor: UnsignedEvent,
|
||||||
|
send_task: Task<Vec<SendReport>>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let id = rumor.id.expect("rumor must have an id");
|
let id = rumor.id.expect("rumor must have an id");
|
||||||
|
let sent_ids = self.sent_ids.clone();
|
||||||
|
|
||||||
// Insert optimistic message and clear input
|
// Insert optimistic message and clear input
|
||||||
if rumor.kind != Kind::Reaction {
|
if rumor.kind != Kind::Reaction {
|
||||||
@@ -487,6 +551,10 @@ impl ChatPanel {
|
|||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
this.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
self.replies_to.update(cx, |this, cx| {
|
self.replies_to.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -604,7 +672,7 @@ impl ChatPanel {
|
|||||||
let Some(message) = self.message(id) else {
|
let Some(message) = self.message(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let content = message.content.to_string();
|
let content = message.preview().to_string();
|
||||||
let item = ClipboardItem::new_string(content);
|
let item = ClipboardItem::new_string(content);
|
||||||
|
|
||||||
cx.write_to_clipboard(item);
|
cx.write_to_clipboard(item);
|
||||||
@@ -630,6 +698,9 @@ impl ChatPanel {
|
|||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
|
|
||||||
|
// Encrypt attachments which are not part of a message being written
|
||||||
|
let encrypted = self.input.read(cx).value().trim().is_empty();
|
||||||
|
|
||||||
// Ask user for file upload
|
// Ask user for file upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
@@ -639,36 +710,95 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
this.update(cx, |this, cx| {
|
// Selecting no file means the prompt was cancelled
|
||||||
this.set_uploading(true, cx);
|
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.upload_file(server, path, encrypted, window, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut paths = path.await??.context("Not found")?;
|
Ok(())
|
||||||
let path = paths.pop().context("No path")?;
|
}));
|
||||||
|
|
||||||
// Upload via blossom client
|
|
||||||
match upload(server, path, cx).await {
|
|
||||||
Ok(url) => {
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.add_attachment(url, cx);
|
|
||||||
this.set_uploading(false, cx);
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
|
/// Upload a file, encrypted when the attachment is the whole message
|
||||||
|
fn upload_file(
|
||||||
|
&mut self,
|
||||||
|
server: Url,
|
||||||
|
path: PathBuf,
|
||||||
|
encrypted: bool,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.set_uploading(true, cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = if encrypted {
|
||||||
|
upload_encrypted(server.clone(), path.clone(), cx)
|
||||||
|
.await
|
||||||
|
.map(|file| Uploaded::File(file, path.clone()))
|
||||||
|
} else {
|
||||||
|
upload(server.clone(), path.clone(), cx)
|
||||||
|
.await
|
||||||
|
.map(Uploaded::Url)
|
||||||
|
};
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
||||||
|
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
||||||
|
Err(e) if encrypted => {
|
||||||
|
this.report_encrypted_upload_error(server, path, e, window, cx)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report a failed encrypted upload, offering to retry it without encryption
|
||||||
|
fn report_encrypted_upload_error(
|
||||||
|
&mut self,
|
||||||
|
server: Url,
|
||||||
|
path: PathBuf,
|
||||||
|
error: Error,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(error.to_string())
|
||||||
|
.title("Encrypted upload failed")
|
||||||
|
.action(move |_this, _window, _cx| {
|
||||||
|
let view = view.clone();
|
||||||
|
let server = server.clone();
|
||||||
|
let path = path.clone();
|
||||||
|
|
||||||
|
Button::new("retry-without-encryption")
|
||||||
|
.label("Upload without encryption")
|
||||||
|
.on_click(move |_ev, window, cx| {
|
||||||
|
view.update(cx, |this, cx| {
|
||||||
|
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||||
self.uploading = uploading;
|
self.uploading = uploading;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -690,6 +820,88 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
this.push(PendingFile { file, path });
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
|
||||||
|
this.remove(ix);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt the attachment of a file message for preview
|
||||||
|
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
|
||||||
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let result = download_and_decrypt_to_file(&file, cx).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(path) => {
|
||||||
|
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt the attachment of a file message and open it with the OS
|
||||||
|
fn open_file(
|
||||||
|
&mut self,
|
||||||
|
id: EventId,
|
||||||
|
file: FileAttachment,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
match self.decrypted_files.get(&id) {
|
||||||
|
Some(DecryptedFile::Ready(path)) => {
|
||||||
|
cx.open_url(&file_url(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(DecryptedFile::Loading) => return,
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = download_and_decrypt_to_file(&file, cx).await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(path) => {
|
||||||
|
cx.open_url(&file_url(&path));
|
||||||
|
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.decrypted_files
|
||||||
|
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
persons.read(cx).get(public_key, cx)
|
persons.read(cx).get(public_key, cx)
|
||||||
@@ -929,6 +1141,16 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
let file = self.messages.get(ix).and_then(|message| {
|
||||||
|
let file = message.file.clone()?;
|
||||||
|
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
||||||
|
.then_some((message.id, file))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((id, file)) = file {
|
||||||
|
self.load_file(id, file, cx);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(message) = self.messages.get(ix) {
|
if let Some(message) = self.messages.get(ix) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let show_author = self.is_group_start(ix);
|
let show_author = self.is_group_start(ix);
|
||||||
@@ -936,7 +1158,7 @@ impl ChatPanel {
|
|||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
RenderedText::new(&message.content, &message.mentions, &persons, cx)
|
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
|
||||||
})
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
@@ -1016,8 +1238,11 @@ impl ChatPanel {
|
|||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.child(rendered_text)
|
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||||
.child(self.render_media(&message.media, cx))
|
.child(self.render_media(&message.media, cx))
|
||||||
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
|
})
|
||||||
.when(has_reactions, |this| {
|
.when(has_reactions, |this| {
|
||||||
this.child(self.render_reactions(&id, cx))
|
this.child(self.render_reactions(&id, cx))
|
||||||
}),
|
}),
|
||||||
@@ -1123,7 +1348,7 @@ impl ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(SharedString::from(&message.content)),
|
.child(message.preview()),
|
||||||
)
|
)
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1427,7 +1652,7 @@ impl ChatPanel {
|
|||||||
.size_16()
|
.size_16()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.object_fit(ObjectFit::ScaleDown),
|
.object_fit(ObjectFit::Cover),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1464,6 +1689,159 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the encrypted file attachment of a message
|
||||||
|
fn render_message_file(
|
||||||
|
&self,
|
||||||
|
id: &EventId,
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let state = self.decrypted_files.get(id);
|
||||||
|
|
||||||
|
if let Some(path) = state
|
||||||
|
.and_then(|state| match state {
|
||||||
|
DecryptedFile::Ready(path) => Some(path),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|_| file.is_image())
|
||||||
|
{
|
||||||
|
return div()
|
||||||
|
.child(
|
||||||
|
img(path.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.h(px(250.))
|
||||||
|
.object_fit(ObjectFit::Cover)
|
||||||
|
.rounded(cx.theme().radius),
|
||||||
|
)
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = match state {
|
||||||
|
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
|
||||||
|
Some(DecryptedFile::Failed(error)) => error.clone(),
|
||||||
|
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
|
||||||
|
None => SharedString::from("Click to decrypt"),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.render_file_chip(id, file, label, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an encrypted file as a chip which decrypts and opens it on click
|
||||||
|
fn render_file_chip(
|
||||||
|
&self,
|
||||||
|
id: &EventId,
|
||||||
|
file: &FileAttachment,
|
||||||
|
label: SharedString,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
h_flex()
|
||||||
|
.id(SharedString::from(format!("file-{id}")))
|
||||||
|
.self_start()
|
||||||
|
.items_start()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_2()
|
||||||
|
.p_2()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_sm()
|
||||||
|
.child(div().line_height(relative(1.2)).child(file.display_name()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.on_click({
|
||||||
|
let file = file.clone();
|
||||||
|
let id = *id;
|
||||||
|
|
||||||
|
cx.listener(move |this, _, window, cx| {
|
||||||
|
this.open_file(id, file.clone(), window, cx);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an uploaded, encrypted file which is not sent yet
|
||||||
|
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
let file = &pending.file;
|
||||||
|
let label = file.display_name();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(file.url.to_string()))
|
||||||
|
.relative()
|
||||||
|
.w_16()
|
||||||
|
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
|
||||||
|
.map(|this| {
|
||||||
|
if file.is_image() {
|
||||||
|
this.child(
|
||||||
|
img(pending.path.clone())
|
||||||
|
.size_16()
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.object_fit(ObjectFit::Cover),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.size_16()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.child("Preview not available"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.absolute()
|
||||||
|
.top_neg_1()
|
||||||
|
.right_neg_1()
|
||||||
|
.size_4()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded_full()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_variant)
|
||||||
|
.bg(gpui::green())
|
||||||
|
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
||||||
|
)
|
||||||
|
.on_click({
|
||||||
|
let url = file.url.clone();
|
||||||
|
cx.listener(move |this, _, _, cx| {
|
||||||
|
this.remove_pending_file(&url, cx);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pending_file_list(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
|
for pending in self.encrypted_attachments.read(cx).iter() {
|
||||||
|
items.push(self.render_pending_file(pending, cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
if let Some(text) = self.message(id) {
|
if let Some(text) = self.message(id) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -1512,7 +1890,7 @@ impl ChatPanel {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(SharedString::from(&text.content)),
|
.child(text.preview()),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
div()
|
div()
|
||||||
@@ -1640,8 +2018,13 @@ impl Focusable for ChatPanel {
|
|||||||
|
|
||||||
impl Render for ChatPanel {
|
impl Render for ChatPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
|
||||||
|
|
||||||
|
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
||||||
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache(self.id.clone(), 100))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
@@ -1673,10 +2056,8 @@ impl Render for ChatPanel {
|
|||||||
.map(|this| {
|
.map(|this| {
|
||||||
if self.messages.is_empty() {
|
if self.messages.is_empty() {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_end()
|
.justify_end()
|
||||||
.child(self.render_announcement(cx)),
|
.child(self.render_announcement(cx)),
|
||||||
)
|
)
|
||||||
@@ -1701,7 +2082,17 @@ impl Render for ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.children(self.render_attachment_list(window, cx))
|
.children(self.render_attachment_list(window, cx))
|
||||||
|
.children(self.render_pending_file_list(window, cx))
|
||||||
.children(self.render_reply_list(window, cx))
|
.children(self.render_reply_list(window, cx))
|
||||||
|
.when(is_typing && pending_attachments, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.px_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_warning)
|
||||||
|
.child(WARNING),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_end()
|
.items_end()
|
||||||
|
|||||||
+120
-31
@@ -1,17 +1,19 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use chat::Mention;
|
use chat::Mention;
|
||||||
use common::RangeExt;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
|
use regex::Regex;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||||
|
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||||
|
|
||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Code,
|
Code,
|
||||||
@@ -39,25 +41,61 @@ impl RenderedText {
|
|||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[Mention],
|
mentions: &[Mention],
|
||||||
persons: &Entity<PersonRegistry>,
|
persons: &Entity<PersonRegistry>,
|
||||||
|
markdown: bool,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
|
) -> Self {
|
||||||
|
Self::render(content, mentions, markdown, |mention| {
|
||||||
|
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
content: &str,
|
||||||
|
mentions: &[Mention],
|
||||||
|
markdown: bool,
|
||||||
|
resolve_mention: impl Fn(&Mention) -> String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
let mut link_ranges = Vec::new();
|
let mut link_ranges = Vec::new();
|
||||||
let mut link_urls = Vec::new();
|
let mut link_urls = Vec::new();
|
||||||
|
|
||||||
render_plain_text_mut(
|
render_text_mut(
|
||||||
content,
|
content,
|
||||||
mentions,
|
mentions,
|
||||||
&mut text,
|
&mut text,
|
||||||
&mut highlights,
|
&mut highlights,
|
||||||
&mut link_ranges,
|
&mut link_ranges,
|
||||||
&mut link_urls,
|
&mut link_urls,
|
||||||
persons,
|
markdown,
|
||||||
cx,
|
resolve_mention,
|
||||||
);
|
);
|
||||||
|
|
||||||
text.truncate(text.trim_end().len());
|
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||||
|
let trimmed_len = text.trim_end().len();
|
||||||
|
|
||||||
|
// Retain highlights and link ranges that are within the trimmed text.
|
||||||
|
if trimmed_len < text.len() {
|
||||||
|
highlights.retain_mut(|(range, _)| {
|
||||||
|
range.end = range.end.min(trimmed_len);
|
||||||
|
range.start < range.end
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut ix = 0;
|
||||||
|
|
||||||
|
while ix < link_ranges.len() {
|
||||||
|
let range = &mut link_ranges[ix];
|
||||||
|
range.end = range.end.min(trimmed_len);
|
||||||
|
if range.start < range.end {
|
||||||
|
ix += 1;
|
||||||
|
} else {
|
||||||
|
link_ranges.remove(ix);
|
||||||
|
link_urls.remove(ix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.truncate(trimmed_len);
|
||||||
|
}
|
||||||
|
|
||||||
RenderedText {
|
RenderedText {
|
||||||
text: SharedString::from(text),
|
text: SharedString::from(text),
|
||||||
@@ -70,10 +108,18 @@ impl RenderedText {
|
|||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let code_background = cx.theme().elevated_surface_background;
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
let color = cx.theme().text_accent;
|
let color = cx.theme().text_accent;
|
||||||
|
let code_font = if cfg!(target_os = "macos") {
|
||||||
|
"Menlo"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"Consolas"
|
||||||
|
} else {
|
||||||
|
"monospace"
|
||||||
|
};
|
||||||
|
|
||||||
InteractiveText::new(
|
InteractiveText::new(
|
||||||
id,
|
id,
|
||||||
StyledText::new(self.text.clone()).with_default_highlights(
|
StyledText::new(self.text.clone())
|
||||||
|
.with_default_highlights(
|
||||||
&window.text_style(),
|
&window.text_style(),
|
||||||
self.highlights.iter().map(|(range, highlight)| {
|
self.highlights.iter().map(|(range, highlight)| {
|
||||||
(
|
(
|
||||||
@@ -112,13 +158,21 @@ impl RenderedText {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||||
|
|(range, highlight)| match highlight {
|
||||||
|
Highlight::Code | Highlight::InlineCode(_) => {
|
||||||
|
Some((range.clone(), code_font.into()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.on_click(self.link_ranges.clone(), {
|
.on_click(self.link_ranges.clone(), {
|
||||||
let link_urls = self.link_urls.clone();
|
let link_urls = self.link_urls.clone();
|
||||||
move |ix, _, cx| {
|
move |ix, _, cx| {
|
||||||
let url = &link_urls[ix];
|
let url = &link_urls[ix];
|
||||||
if url.starts_with("http") {
|
if WEB_URL.is_match(url) {
|
||||||
cx.open_url(url);
|
cx.open_url(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,15 +182,15 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_plain_text_mut(
|
fn render_text_mut(
|
||||||
block: &str,
|
block: &str,
|
||||||
mut mentions: &[Mention],
|
mut mentions: &[Mention],
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
persons: &Entity<PersonRegistry>,
|
markdown: bool,
|
||||||
cx: &App,
|
resolve_mention: impl Fn(&Mention) -> String,
|
||||||
) {
|
) {
|
||||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||||
|
|
||||||
@@ -145,34 +199,58 @@ fn render_plain_text_mut(
|
|||||||
let mut strikethrough_depth = 0;
|
let mut strikethrough_depth = 0;
|
||||||
let mut link_url = None;
|
let mut link_url = None;
|
||||||
let mut list_stack = Vec::new();
|
let mut list_stack = Vec::new();
|
||||||
|
let mut code_block = false;
|
||||||
|
|
||||||
let mut options = Options::all();
|
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||||
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||||
|
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||||
|
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||||
|
} else {
|
||||||
|
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||||
|
};
|
||||||
|
|
||||||
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
for (event, source_range) in events {
|
||||||
let prev_len = text.len();
|
let prev_len = text.len();
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::Text(t) => {
|
Event::Text(t) => {
|
||||||
// Process text with mention replacements
|
if code_block {
|
||||||
|
text.push_str(t.as_ref());
|
||||||
|
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let t_str = t.as_ref();
|
let t_str = t.as_ref();
|
||||||
let mut last_processed = 0;
|
let mut last_processed = 0;
|
||||||
|
|
||||||
while let Some(mention) = mentions.first() {
|
while let Some(mention) = mentions.first() {
|
||||||
if !source_range.contains_inclusive(&mention.range) {
|
if mention.range.start >= source_range.end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate positions within the current text
|
mentions = &mentions[1..];
|
||||||
let mention_start_in_text = mention.range.start - source_range.start;
|
if mention.range.start < source_range.start
|
||||||
let mention_end_in_text = mention.range.end - source_range.start;
|
|| mention.range.end > source_range.end
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(token) = block.get(mention.range.clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mention_start_in_text = last_processed + offset;
|
||||||
|
let mention_end_in_text = mention_start_in_text + token.len();
|
||||||
|
|
||||||
// Add text before this mention
|
// Add text before this mention
|
||||||
if mention_start_in_text > last_processed {
|
if mention_start_in_text > last_processed {
|
||||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||||
process_text_segment(
|
process_text_segment(
|
||||||
before_mention,
|
before_mention,
|
||||||
prev_len + last_processed,
|
|
||||||
bold_depth,
|
bold_depth,
|
||||||
italic_depth,
|
italic_depth,
|
||||||
strikethrough_depth,
|
strikethrough_depth,
|
||||||
@@ -185,9 +263,7 @@ fn render_plain_text_mut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process the mention replacement
|
// Process the mention replacement
|
||||||
let profile = persons.read(cx).get(&mention.public_key, cx);
|
let replacement_text = resolve_mention(mention);
|
||||||
let replacement_text = format!("@{}", profile.name());
|
|
||||||
|
|
||||||
let replacement_start = text.len();
|
let replacement_start = text.len();
|
||||||
text.push_str(&replacement_text);
|
text.push_str(&replacement_text);
|
||||||
let replacement_end = text.len();
|
let replacement_end = text.len();
|
||||||
@@ -195,7 +271,6 @@ fn render_plain_text_mut(
|
|||||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||||
|
|
||||||
last_processed = mention_end_in_text;
|
last_processed = mention_end_in_text;
|
||||||
mentions = &mentions[1..];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last mention
|
// Add any remaining text after the last mention
|
||||||
@@ -203,7 +278,6 @@ fn render_plain_text_mut(
|
|||||||
let remaining_text = &t_str[last_processed..];
|
let remaining_text = &t_str[last_processed..];
|
||||||
process_text_segment(
|
process_text_segment(
|
||||||
remaining_text,
|
remaining_text,
|
||||||
prev_len + last_processed,
|
|
||||||
bold_depth,
|
bold_depth,
|
||||||
italic_depth,
|
italic_depth,
|
||||||
strikethrough_depth,
|
strikethrough_depth,
|
||||||
@@ -234,11 +308,14 @@ fn render_plain_text_mut(
|
|||||||
}
|
}
|
||||||
Tag::CodeBlock(_kind) => {
|
Tag::CodeBlock(_kind) => {
|
||||||
new_paragraph(text, &mut list_stack);
|
new_paragraph(text, &mut list_stack);
|
||||||
|
code_block = true;
|
||||||
}
|
}
|
||||||
Tag::Emphasis => italic_depth += 1,
|
Tag::Emphasis => italic_depth += 1,
|
||||||
Tag::Strong => bold_depth += 1,
|
Tag::Strong => bold_depth += 1,
|
||||||
Tag::Strikethrough => strikethrough_depth += 1,
|
Tag::Strikethrough => strikethrough_depth += 1,
|
||||||
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
Tag::Link { dest_url, .. } => {
|
||||||
|
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||||
|
}
|
||||||
Tag::List(number) => {
|
Tag::List(number) => {
|
||||||
list_stack.push((number, false));
|
list_stack.push((number, false));
|
||||||
}
|
}
|
||||||
@@ -264,6 +341,7 @@ fn render_plain_text_mut(
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Event::End(tag) => match tag {
|
Event::End(tag) => match tag {
|
||||||
|
TagEnd::CodeBlock => code_block = false,
|
||||||
TagEnd::Heading(_) => bold_depth -= 1,
|
TagEnd::Heading(_) => bold_depth -= 1,
|
||||||
TagEnd::Emphasis => italic_depth -= 1,
|
TagEnd::Emphasis => italic_depth -= 1,
|
||||||
TagEnd::Strong => bold_depth -= 1,
|
TagEnd::Strong => bold_depth -= 1,
|
||||||
@@ -272,6 +350,11 @@ fn render_plain_text_mut(
|
|||||||
TagEnd::List(_) => drop(list_stack.pop()),
|
TagEnd::List(_) => drop(list_stack.pop()),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||||
|
Event::Rule => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
text.push_str("────────\n");
|
||||||
|
}
|
||||||
Event::HardBreak => text.push('\n'),
|
Event::HardBreak => text.push('\n'),
|
||||||
Event::SoftBreak => text.push('\n'),
|
Event::SoftBreak => text.push('\n'),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -282,7 +365,6 @@ fn render_plain_text_mut(
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn process_text_segment(
|
fn process_text_segment(
|
||||||
segment: &str,
|
segment: &str,
|
||||||
segment_start: usize,
|
|
||||||
bold_depth: i32,
|
bold_depth: i32,
|
||||||
italic_depth: i32,
|
italic_depth: i32,
|
||||||
strikethrough_depth: i32,
|
strikethrough_depth: i32,
|
||||||
@@ -307,7 +389,8 @@ fn process_text_segment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the text
|
// Ranges always refer to the rendered text, including replaced mentions.
|
||||||
|
let segment_start = text.len();
|
||||||
text.push_str(segment);
|
text.push_str(segment);
|
||||||
let text_end = text.len();
|
let text_end = text.len();
|
||||||
|
|
||||||
@@ -330,7 +413,10 @@ fn process_text_segment(
|
|||||||
finder.kinds(&[linkify::LinkKind::Url]);
|
finder.kinds(&[linkify::LinkKind::Url]);
|
||||||
let mut last_link_pos = 0;
|
let mut last_link_pos = 0;
|
||||||
|
|
||||||
for link in finder.links(segment) {
|
for link in finder
|
||||||
|
.links(segment)
|
||||||
|
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||||
|
{
|
||||||
let start = link.start();
|
let start = link.start();
|
||||||
let end = link.end();
|
let end = link.end();
|
||||||
|
|
||||||
@@ -375,6 +461,7 @@ fn process_text_segment(
|
|||||||
|
|
||||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||||
let mut is_subsequent_paragraph_of_list = false;
|
let mut is_subsequent_paragraph_of_list = false;
|
||||||
|
|
||||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||||
if *has_content {
|
if *has_content {
|
||||||
is_subsequent_paragraph_of_list = true;
|
is_subsequent_paragraph_of_list = true;
|
||||||
@@ -390,9 +477,11 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
|||||||
}
|
}
|
||||||
text.push('\n');
|
text.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||||
text.push_str(" ");
|
text.push_str(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_subsequent_paragraph_of_list {
|
if is_subsequent_paragraph_of_list {
|
||||||
text.push_str(" ");
|
text.push_str(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::mem::take;
|
|
||||||
|
|
||||||
use futures::FutureExt;
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
|
||||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
|
||||||
CoopImageCacheProvider {
|
|
||||||
id: id.into(),
|
|
||||||
max_items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCacheProvider {
|
|
||||||
id: ElementId,
|
|
||||||
max_items: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCacheProvider for CoopImageCacheProvider {
|
|
||||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
|
||||||
window
|
|
||||||
.with_global_id(self.id.clone(), |id, window| {
|
|
||||||
window.with_element_state(id, |cache, _| {
|
|
||||||
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
|
||||||
(cache.clone(), cache)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CoopImageCache {
|
|
||||||
max_items: usize,
|
|
||||||
usage_list: VecDeque<u64>,
|
|
||||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CoopImageCache {
|
|
||||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| {
|
|
||||||
log::info!("Creating CoopImageCache");
|
|
||||||
cx.on_release(|this: &mut Self, cx| {
|
|
||||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
|
||||||
if let Some(Ok(image)) = image.get() {
|
|
||||||
log::info!("Dropping image {ix}");
|
|
||||||
cx.drop_image(image, None);
|
|
||||||
}
|
|
||||||
ImageSource::Resource(resource).remove_asset(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
CoopImageCache {
|
|
||||||
max_items,
|
|
||||||
usage_list: VecDeque::with_capacity(max_items),
|
|
||||||
cache: HashMap::with_capacity(max_items),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageCache for CoopImageCache {
|
|
||||||
fn load(
|
|
||||||
&mut self,
|
|
||||||
resource: &Resource,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::App,
|
|
||||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
|
||||||
let hash = hash(resource);
|
|
||||||
|
|
||||||
if let Some(item) = self.cache.get_mut(&hash) {
|
|
||||||
let current_idx = self
|
|
||||||
.usage_list
|
|
||||||
.iter()
|
|
||||||
.position(|item| *item == hash)
|
|
||||||
.expect("cache has an item usage_list doesn't");
|
|
||||||
|
|
||||||
self.usage_list.remove(current_idx);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
return item.0.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
|
||||||
let task = cx.background_executor().spawn(load_future).shared();
|
|
||||||
|
|
||||||
if self.usage_list.len() >= self.max_items {
|
|
||||||
log::info!("Image cache is full, evicting oldest item");
|
|
||||||
|
|
||||||
if let Some(oldest) = self.usage_list.pop_back() {
|
|
||||||
let mut image = self
|
|
||||||
.cache
|
|
||||||
.remove(&oldest)
|
|
||||||
.expect("usage_list has an item cache doesn't");
|
|
||||||
|
|
||||||
if let Some(Ok(image)) = image.0.get() {
|
|
||||||
log::info!("requesting image to be dropped");
|
|
||||||
cx.drop_image(image, Some(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageSource::Resource(image.1).remove_asset(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache.insert(
|
|
||||||
hash,
|
|
||||||
(
|
|
||||||
gpui::ImageCacheItem::Loading(task.clone()),
|
|
||||||
resource.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
self.usage_list.push_front(hash);
|
|
||||||
|
|
||||||
let entity = window.current_view();
|
|
||||||
|
|
||||||
window
|
|
||||||
.spawn(cx, async move |cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
log::error!("error loading image into cache: {:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.on_next_frame(move |_, cx| {
|
|
||||||
cx.notify(entity);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub use caching::*;
|
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
@@ -7,7 +6,6 @@ pub use parser::*;
|
|||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
pub use range::*;
|
pub use range::*;
|
||||||
|
|
||||||
mod caching;
|
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "concord"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nostr.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
hkdf.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
nostr-memory.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
@@ -0,0 +1,947 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::derive::channel_group_key;
|
||||||
|
use crate::edition::{
|
||||||
|
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||||
|
};
|
||||||
|
use crate::stream::{
|
||||||
|
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
|
||||||
|
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
|
||||||
|
wrap_seal,
|
||||||
|
};
|
||||||
|
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
|
||||||
|
|
||||||
|
pub const KIND_MESSAGE: u16 = 9;
|
||||||
|
pub const KIND_COMMENT: u16 = 1111;
|
||||||
|
pub const KIND_REACTION: u16 = 7;
|
||||||
|
pub const KIND_DELETE: u16 = 5;
|
||||||
|
pub const KIND_EDIT: u16 = 3302;
|
||||||
|
pub const KIND_FILE: u16 = 15;
|
||||||
|
pub const KIND_WEBXDC: u16 = 3310;
|
||||||
|
pub const KIND_TYPING: u16 = 23311;
|
||||||
|
|
||||||
|
const TAG_QUOTE: &str = "q";
|
||||||
|
const TAG_TARGET: &str = "e";
|
||||||
|
const TAG_TARGET_KIND: &str = "k";
|
||||||
|
const TAG_ROOT: &str = "E";
|
||||||
|
const TAG_ROOT_KIND: &str = "K";
|
||||||
|
const TAG_ROOT_AUTHOR: &str = "P";
|
||||||
|
const TAG_TARGET_AUTHOR: &str = "p";
|
||||||
|
const TAG_EXPIRATION: &str = "expiration";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ChatError {
|
||||||
|
Stream(StreamError),
|
||||||
|
NotEncryptedSealed,
|
||||||
|
UnknownKind(u16),
|
||||||
|
MissingTag(&'static str),
|
||||||
|
DuplicateTag(&'static str),
|
||||||
|
BadTag(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ChatError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
ChatError::Stream(error) => write!(f, "stream: {error}"),
|
||||||
|
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
|
||||||
|
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
|
||||||
|
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
|
||||||
|
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
|
||||||
|
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ChatError {}
|
||||||
|
|
||||||
|
impl From<StreamError> for ChatError {
|
||||||
|
fn from(error: StreamError) -> Self {
|
||||||
|
ChatError::Stream(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chat event another chat event refers to: a quote, a comment's parent, a
|
||||||
|
/// reaction's target. The author slot is a SHOULD on the wire, so it is optional.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ReplyRef {
|
||||||
|
pub id: EventId,
|
||||||
|
pub author: Option<PublicKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference that also names the referenced event's kind, which a comment
|
||||||
|
/// (`K`/`k`) and a reaction (`k`) must commit on the wire.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Target {
|
||||||
|
pub reply: ReplyRef,
|
||||||
|
pub kind: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ChatAction {
|
||||||
|
Message {
|
||||||
|
reply_to: Option<ReplyRef>,
|
||||||
|
thread_root: Option<ReplyRef>,
|
||||||
|
},
|
||||||
|
Reaction {
|
||||||
|
target: EventId,
|
||||||
|
emoji: String,
|
||||||
|
},
|
||||||
|
Edit {
|
||||||
|
target: EventId,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
target: EventId,
|
||||||
|
target_kind: Option<u16>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
},
|
||||||
|
Typing,
|
||||||
|
Opaque,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChatRumor {
|
||||||
|
pub id: EventId,
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub channel: ChannelId,
|
||||||
|
pub epoch: Epoch,
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub content: String,
|
||||||
|
pub expiration: Option<Timestamp>,
|
||||||
|
pub action: ChatAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel's timeline row, with every edit, delete and reaction folded in.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub id: EventId,
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub channel: ChannelId,
|
||||||
|
pub epoch: Epoch,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub content: String,
|
||||||
|
pub reply_to: Option<EventId>,
|
||||||
|
pub thread_root: Option<EventId>,
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub expiration: Option<Timestamp>,
|
||||||
|
pub edited_at: Option<u64>,
|
||||||
|
pub deleted: bool,
|
||||||
|
pub reactions: BTreeMap<PublicKey, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_message(
|
||||||
|
author: PublicKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
content: &str,
|
||||||
|
quote: Option<&ReplyRef>,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut tags = channel_binding_tags(channel, epoch);
|
||||||
|
|
||||||
|
if let Some(quote) = quote {
|
||||||
|
tags.push(reply_tag(TAG_QUOTE, quote));
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
|
||||||
|
/// immutable root; `None` means the parent is itself the root.
|
||||||
|
pub fn build_comment(
|
||||||
|
author: PublicKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
content: &str,
|
||||||
|
parent: &Target,
|
||||||
|
root: Option<&Target>,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let root = root.unwrap_or(parent);
|
||||||
|
let mut tags = channel_binding_tags(channel, epoch);
|
||||||
|
|
||||||
|
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
|
||||||
|
tags.push(reply_tag(TAG_ROOT, &root.reply));
|
||||||
|
if let Some(root_author) = root.reply.author {
|
||||||
|
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
|
||||||
|
tags.push(reply_tag(TAG_TARGET, &parent.reply));
|
||||||
|
if let Some(parent_author) = parent.reply.author {
|
||||||
|
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_reaction(
|
||||||
|
author: PublicKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
target: &Target,
|
||||||
|
emoji: &str,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut tags = channel_binding_tags(channel, epoch);
|
||||||
|
|
||||||
|
tags.push(Tag::custom(TAG_TARGET, [target.reply.id.to_hex()]));
|
||||||
|
if let Some(target_author) = target.reply.author {
|
||||||
|
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [target_author.to_hex()]));
|
||||||
|
}
|
||||||
|
tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()]));
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_edit(
|
||||||
|
author: PublicKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
target: EventId,
|
||||||
|
content: &str,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut tags = channel_binding_tags(channel, epoch);
|
||||||
|
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_delete(
|
||||||
|
author: PublicKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
target: EventId,
|
||||||
|
target_kind: Option<u16>,
|
||||||
|
citation: Option<&AuthorityCitation>,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut tags = channel_binding_tags(channel, epoch);
|
||||||
|
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
|
||||||
|
|
||||||
|
if let Some(target_kind) = target_kind {
|
||||||
|
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(citation) = citation {
|
||||||
|
tags.push(citation_tag(citation));
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_typing(
|
||||||
|
author: PublicKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
build_rumor_ms(
|
||||||
|
KIND_TYPING,
|
||||||
|
author,
|
||||||
|
"",
|
||||||
|
channel_binding_tags(channel, epoch),
|
||||||
|
at_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals a chat rumor and wraps it at the channel's address. `ephemeral` picks
|
||||||
|
/// the 21059 wrap, which relays must not store.
|
||||||
|
pub fn seal_rumor(
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
group: &GroupKey,
|
||||||
|
author: &Keys,
|
||||||
|
ephemeral: bool,
|
||||||
|
) -> Result<(Event, Keys), ChatError> {
|
||||||
|
let kind = rumor.kind.as_u16();
|
||||||
|
|
||||||
|
if !is_chat_kind(kind) {
|
||||||
|
return Err(ChatError::UnknownKind(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
||||||
|
let wrap_kind = if ephemeral {
|
||||||
|
KIND_WRAP_EPHEMERAL
|
||||||
|
} else {
|
||||||
|
KIND_WRAP
|
||||||
|
};
|
||||||
|
|
||||||
|
// CORD-08 §2: a NIP-40 expiration rides the wrap as well, so relays drop the
|
||||||
|
// stored event on schedule; the inner copy is what drives a local purge.
|
||||||
|
let expiration: Vec<Tag> = rumor
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some(TAG_EXPIRATION))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(wrap_seal(
|
||||||
|
&seal,
|
||||||
|
group,
|
||||||
|
wrap_kind,
|
||||||
|
rumor.created_at,
|
||||||
|
&expiration,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a wrap against the plane whose key is tried. The channel and epoch the
|
||||||
|
/// rumor claims must both be the ones that opened it, so a keyholder of two
|
||||||
|
/// planes cannot re-seal a rumor elsewhere or replay it across an epoch.
|
||||||
|
pub fn open(
|
||||||
|
wrap: &Event,
|
||||||
|
group: &GroupKey,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
) -> Result<(OpenedStream, ChatRumor), ChatError> {
|
||||||
|
let opened = open_wrap(wrap, group)?;
|
||||||
|
|
||||||
|
if opened.seal_form != SealForm::Encrypted {
|
||||||
|
return Err(ChatError::NotEncryptedSealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
check_channel_binding(&opened.rumor, channel, epoch)?;
|
||||||
|
|
||||||
|
let chat = typed(&opened.rumor, channel, epoch)?;
|
||||||
|
|
||||||
|
Ok((opened, chat))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every epoch's group key for one channel. `secret` is whatever feeds the
|
||||||
|
/// channel at that epoch: the `community_root` for a public one, its own key
|
||||||
|
/// for a private one.
|
||||||
|
pub fn plane_keys(
|
||||||
|
held: &[(Epoch, [u8; 32])],
|
||||||
|
channel: &ChannelId,
|
||||||
|
) -> Result<Vec<(Epoch, GroupKey)>> {
|
||||||
|
held.iter()
|
||||||
|
.map(|(epoch, secret)| Ok((*epoch, channel_group_key(secret, channel, *epoch)?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fold(
|
||||||
|
rumors: &[ChatRumor],
|
||||||
|
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
|
||||||
|
) -> Vec<ChatMessage> {
|
||||||
|
let mut order: Vec<usize> = (0..rumors.len()).collect();
|
||||||
|
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
|
||||||
|
|
||||||
|
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||||
|
let mut slot: BTreeMap<EventId, usize> = BTreeMap::new();
|
||||||
|
|
||||||
|
for index in order {
|
||||||
|
let rumor = &rumors[index];
|
||||||
|
|
||||||
|
let ChatAction::Message {
|
||||||
|
reply_to,
|
||||||
|
thread_root,
|
||||||
|
} = &rumor.action
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
slot.insert(rumor.id, messages.len());
|
||||||
|
messages.push(ChatMessage {
|
||||||
|
id: rumor.id,
|
||||||
|
author: rumor.author,
|
||||||
|
channel: rumor.channel,
|
||||||
|
epoch: rumor.epoch,
|
||||||
|
kind: rumor.kind,
|
||||||
|
content: rumor.content.clone(),
|
||||||
|
reply_to: reply_to.map(|reply| reply.id),
|
||||||
|
thread_root: thread_root.map(|reply| reply.id),
|
||||||
|
at_ms: rumor.at_ms,
|
||||||
|
expiration: rumor.expiration,
|
||||||
|
edited_at: None,
|
||||||
|
deleted: false,
|
||||||
|
reactions: BTreeMap::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mutations: Vec<usize> = (0..rumors.len()).collect();
|
||||||
|
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
|
||||||
|
|
||||||
|
for index in mutations {
|
||||||
|
let rumor = &rumors[index];
|
||||||
|
|
||||||
|
match &rumor.action {
|
||||||
|
ChatAction::Edit { target, content } => {
|
||||||
|
let Some(&slot) = slot.get(target) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let message = &mut messages[slot];
|
||||||
|
|
||||||
|
if message.deleted || message.author != rumor.author {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.content = content.clone();
|
||||||
|
message.edited_at = Some(rumor.at_ms);
|
||||||
|
}
|
||||||
|
ChatAction::Delete {
|
||||||
|
target, citation, ..
|
||||||
|
} => {
|
||||||
|
let Some(&slot) = slot.get(target) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let author = messages[slot].author;
|
||||||
|
|
||||||
|
if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) {
|
||||||
|
messages[slot].deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatAction::Reaction { target, emoji } => {
|
||||||
|
let Some(&slot) = slot.get(target) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
messages[slot].reactions.insert(rumor.author, emoji.clone());
|
||||||
|
}
|
||||||
|
ChatAction::Message { .. } | ChatAction::Typing | ChatAction::Opaque => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.sort_by_key(|message| (Reverse(message.at_ms), message.id));
|
||||||
|
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_chat_kind(kind: u16) -> bool {
|
||||||
|
matches!(
|
||||||
|
kind,
|
||||||
|
KIND_MESSAGE
|
||||||
|
| KIND_COMMENT
|
||||||
|
| KIND_REACTION
|
||||||
|
| KIND_DELETE
|
||||||
|
| KIND_EDIT
|
||||||
|
| KIND_FILE
|
||||||
|
| KIND_WEBXDC
|
||||||
|
| KIND_TYPING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<ChatRumor, ChatError> {
|
||||||
|
Ok(ChatRumor {
|
||||||
|
id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
||||||
|
author: rumor.pubkey,
|
||||||
|
kind: rumor.kind,
|
||||||
|
channel: *channel,
|
||||||
|
epoch,
|
||||||
|
at_ms: resolve_ms_strict(rumor)?,
|
||||||
|
content: rumor.content.clone(),
|
||||||
|
expiration: expiration_of(rumor)?,
|
||||||
|
action: action_of(rumor)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
|
||||||
|
let kind = rumor.kind.as_u16();
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
KIND_MESSAGE | KIND_FILE => Ok(ChatAction::Message {
|
||||||
|
reply_to: optional_reply(rumor, TAG_QUOTE)?,
|
||||||
|
thread_root: None,
|
||||||
|
}),
|
||||||
|
KIND_COMMENT => Ok(ChatAction::Message {
|
||||||
|
reply_to: optional_reply(rumor, TAG_TARGET)?,
|
||||||
|
thread_root: optional_reply(rumor, TAG_ROOT)?,
|
||||||
|
}),
|
||||||
|
KIND_REACTION => Ok(ChatAction::Reaction {
|
||||||
|
target: required_id(rumor, TAG_TARGET)?,
|
||||||
|
emoji: rumor.content.clone(),
|
||||||
|
}),
|
||||||
|
KIND_EDIT => Ok(ChatAction::Edit {
|
||||||
|
target: required_id(rumor, TAG_TARGET)?,
|
||||||
|
content: rumor.content.clone(),
|
||||||
|
}),
|
||||||
|
KIND_DELETE => Ok(ChatAction::Delete {
|
||||||
|
target: required_id(rumor, TAG_TARGET)?,
|
||||||
|
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
|
||||||
|
citation: optional_citation(rumor)?,
|
||||||
|
}),
|
||||||
|
KIND_TYPING => Ok(ChatAction::Typing),
|
||||||
|
KIND_WEBXDC => Ok(ChatAction::Opaque),
|
||||||
|
other => Err(ChatError::UnknownKind(other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_reply(
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<Option<ReplyRef>, ChatError> {
|
||||||
|
let Some(fields) = tag(rumor, name)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3.
|
||||||
|
let author = match fields.get(3).map(String::as_str) {
|
||||||
|
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ReplyRef {
|
||||||
|
id: hex_id(fields, name)?,
|
||||||
|
author,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_id(rumor: &UnsignedEvent, name: &'static str) -> Result<EventId, ChatError> {
|
||||||
|
let fields = tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
|
||||||
|
hex_id(fields, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16>, ChatError> {
|
||||||
|
let Some(fields) = tag(rumor, name)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw = value(fields, name)?;
|
||||||
|
let kind = canonical_decimal(raw).ok_or(ChatError::BadTag(name))?;
|
||||||
|
|
||||||
|
u16::try_from(kind)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| ChatError::BadTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
|
||||||
|
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
citation_from(fields)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or(ChatError::BadTag(TAG_CITATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
|
||||||
|
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let seconds = canonical_decimal(value(fields, TAG_EXPIRATION)?)
|
||||||
|
.ok_or(ChatError::BadTag(TAG_EXPIRATION))?;
|
||||||
|
|
||||||
|
Ok(Some(Timestamp::from_secs(seconds)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
|
||||||
|
Tag::custom(
|
||||||
|
name,
|
||||||
|
[
|
||||||
|
reply.id.to_hex(),
|
||||||
|
String::new(),
|
||||||
|
reply
|
||||||
|
.author
|
||||||
|
.map(|author| author.to_hex())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag<'a>(
|
||||||
|
rumor: &'a UnsignedEvent,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<Option<&'a [String]>, ChatError> {
|
||||||
|
let mut found: Option<&[String]> = None;
|
||||||
|
|
||||||
|
for candidate in rumor.tags.iter() {
|
||||||
|
let fields = candidate.as_slice();
|
||||||
|
|
||||||
|
if fields.first().map(String::as_str) != Some(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.is_some() {
|
||||||
|
return Err(ChatError::DuplicateTag(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
found = Some(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
|
||||||
|
fields
|
||||||
|
.get(1)
|
||||||
|
.map(String::as_str)
|
||||||
|
.ok_or(ChatError::BadTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
|
||||||
|
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
|
||||||
|
|
||||||
|
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
|
||||||
|
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
|
||||||
|
|
||||||
|
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SECRET: [u8; 32] = [0x2du8; 32];
|
||||||
|
const AT: u64 = 1_700_000_000_417;
|
||||||
|
|
||||||
|
fn channel() -> ChannelId {
|
||||||
|
ChannelId::from_bytes([0x9cu8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group() -> GroupKey {
|
||||||
|
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
|
||||||
|
seal_rumor(rumor, group, author, false).expect("seals").0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
|
||||||
|
open(&sealed(rumor, group, author), group, &channel(), epoch)
|
||||||
|
.expect("opens")
|
||||||
|
.1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(id: EventId, author: &Keys) -> Target {
|
||||||
|
Target {
|
||||||
|
reply: ReplyRef {
|
||||||
|
id,
|
||||||
|
author: Some(author.public_key()),
|
||||||
|
},
|
||||||
|
kind: KIND_MESSAGE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_holder_folds_edits_reactions_and_a_self_delete() {
|
||||||
|
let alice = Keys::generate();
|
||||||
|
let carol = Keys::generate();
|
||||||
|
let group = group();
|
||||||
|
|
||||||
|
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
|
||||||
|
let id = message.compute_id();
|
||||||
|
|
||||||
|
let rumors = vec![
|
||||||
|
read(&message, &group, &alice, Epoch(0)),
|
||||||
|
read(
|
||||||
|
&build_reaction(
|
||||||
|
carol.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
&target(id, &alice),
|
||||||
|
"🔥",
|
||||||
|
AT + 1_000,
|
||||||
|
),
|
||||||
|
&group,
|
||||||
|
&carol,
|
||||||
|
Epoch(0),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
&build_edit(
|
||||||
|
alice.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
id,
|
||||||
|
"hello (fixed)",
|
||||||
|
AT + 2_000,
|
||||||
|
),
|
||||||
|
&group,
|
||||||
|
&alice,
|
||||||
|
Epoch(0),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
&build_delete(
|
||||||
|
alice.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
id,
|
||||||
|
Some(KIND_MESSAGE),
|
||||||
|
None,
|
||||||
|
AT + 3_000,
|
||||||
|
),
|
||||||
|
&group,
|
||||||
|
&alice,
|
||||||
|
Epoch(0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let folded = fold(&rumors, |_, _, _| false);
|
||||||
|
|
||||||
|
assert_eq!(folded.len(), 1);
|
||||||
|
assert_eq!(folded[0].id, id);
|
||||||
|
assert_eq!(folded[0].content, "hello (fixed)");
|
||||||
|
assert_eq!(folded[0].edited_at, Some(AT + 2_000));
|
||||||
|
assert_eq!(
|
||||||
|
folded[0].reactions.get(&carol.public_key()),
|
||||||
|
Some(&"🔥".to_owned())
|
||||||
|
);
|
||||||
|
assert!(folded[0].deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_or_delete_from_another_author_is_ignored() {
|
||||||
|
let alice = Keys::generate();
|
||||||
|
let bob = Keys::generate();
|
||||||
|
let group = group();
|
||||||
|
|
||||||
|
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
|
||||||
|
let id = message.compute_id();
|
||||||
|
|
||||||
|
let rumors = vec![
|
||||||
|
read(&message, &group, &alice, Epoch(0)),
|
||||||
|
read(
|
||||||
|
&build_edit(
|
||||||
|
bob.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
id,
|
||||||
|
"mine now",
|
||||||
|
AT + 1_000,
|
||||||
|
),
|
||||||
|
&group,
|
||||||
|
&bob,
|
||||||
|
Epoch(0),
|
||||||
|
),
|
||||||
|
read(
|
||||||
|
&build_delete(
|
||||||
|
bob.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
id,
|
||||||
|
Some(KIND_MESSAGE),
|
||||||
|
None,
|
||||||
|
AT + 2_000,
|
||||||
|
),
|
||||||
|
&group,
|
||||||
|
&bob,
|
||||||
|
Epoch(0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let folded = fold(&rumors, |_, _, _| false);
|
||||||
|
|
||||||
|
assert_eq!(folded.len(), 1);
|
||||||
|
assert_eq!(folded[0].content, "hello");
|
||||||
|
assert_eq!(folded[0].edited_at, None);
|
||||||
|
assert!(!folded[0].deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_comment_carries_its_root_and_its_parent() {
|
||||||
|
let alice = Keys::generate();
|
||||||
|
let bob = Keys::generate();
|
||||||
|
let group = group();
|
||||||
|
|
||||||
|
let root = build_message(alice.public_key(), &channel(), Epoch(0), "root", None, AT);
|
||||||
|
let root_id = root.compute_id();
|
||||||
|
let parent = build_message(
|
||||||
|
bob.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
"parent",
|
||||||
|
None,
|
||||||
|
AT + 1_000,
|
||||||
|
);
|
||||||
|
let parent_id = parent.compute_id();
|
||||||
|
|
||||||
|
let comment = build_comment(
|
||||||
|
alice.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
"deep",
|
||||||
|
&target(parent_id, &bob),
|
||||||
|
Some(&target(root_id, &alice)),
|
||||||
|
AT + 2_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"]));
|
||||||
|
assert!(
|
||||||
|
comment
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| { tag.as_slice()[0] == "E" && tag.as_slice()[1] == root_id.to_hex() })
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
comment
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| { tag.as_slice()[0] == "e" && tag.as_slice()[1] == parent_id.to_hex() })
|
||||||
|
);
|
||||||
|
|
||||||
|
let rumor = read(&comment, &group, &alice, Epoch(0));
|
||||||
|
let ChatAction::Message {
|
||||||
|
reply_to,
|
||||||
|
thread_root,
|
||||||
|
} = &rumor.action
|
||||||
|
else {
|
||||||
|
panic!("a comment is a message row")
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(reply_to.map(|reply| reply.id), Some(parent_id));
|
||||||
|
assert_eq!(thread_root.map(|root| root.id), Some(root_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rumor_bound_to_another_channel_or_epoch_is_rejected() {
|
||||||
|
let alice = Keys::generate();
|
||||||
|
let group = group();
|
||||||
|
|
||||||
|
let plain = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
|
||||||
|
assert!(
|
||||||
|
open(
|
||||||
|
&sealed(&plain, &group, &alice),
|
||||||
|
&group,
|
||||||
|
&channel(),
|
||||||
|
Epoch(0)
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The keyholder re-addresses their own rumor: the binding is judged
|
||||||
|
// against the plane whose key opened the wrap, never the rumor's claim.
|
||||||
|
let elsewhere = ChannelId::from_bytes([0xeeu8; 32]);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&sealed(&plain, &group, &alice),
|
||||||
|
&group,
|
||||||
|
&elsewhere,
|
||||||
|
Epoch(0)
|
||||||
|
),
|
||||||
|
Err(ChatError::Stream(StreamError::ChannelMismatch))
|
||||||
|
));
|
||||||
|
|
||||||
|
let stale = build_message(alice.public_key(), &channel(), Epoch(1), "stale", None, AT);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&sealed(&stale, &group, &alice),
|
||||||
|
&group,
|
||||||
|
&channel(),
|
||||||
|
Epoch(0)
|
||||||
|
),
|
||||||
|
Err(ChatError::Stream(StreamError::EpochMismatch))
|
||||||
|
));
|
||||||
|
|
||||||
|
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
|
||||||
|
// chat rumor however well-formed it looks.
|
||||||
|
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
|
||||||
|
let (wrap, _) = wrap_seal(
|
||||||
|
&seal,
|
||||||
|
&group,
|
||||||
|
KIND_WRAP,
|
||||||
|
Timestamp::from_secs(AT / 1000),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.expect("wraps");
|
||||||
|
assert!(matches!(
|
||||||
|
open(&wrap, &group, &channel(), Epoch(0)),
|
||||||
|
Err(ChatError::NotEncryptedSealed)
|
||||||
|
));
|
||||||
|
|
||||||
|
let ghost = build_rumor_ms(
|
||||||
|
3300,
|
||||||
|
alice.public_key(),
|
||||||
|
"v1 ghost",
|
||||||
|
channel_binding_tags(&channel(), Epoch(0)),
|
||||||
|
AT,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
seal_rumor(&ghost, &group, &alice, false),
|
||||||
|
Err(ChatError::UnknownKind(3300))
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut tags = channel_binding_tags(&channel(), Epoch(0));
|
||||||
|
tags.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)]));
|
||||||
|
tags.push(Tag::custom(TAG_TARGET, ["cd".repeat(32)]));
|
||||||
|
let ambiguous = build_rumor_ms(KIND_DELETE, alice.public_key(), "", tags, AT);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&sealed(&ambiguous, &group, &alice),
|
||||||
|
&group,
|
||||||
|
&channel(),
|
||||||
|
Epoch(0)
|
||||||
|
),
|
||||||
|
Err(ChatError::DuplicateTag(TAG_TARGET))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_moderator_delete_needs_the_roster_and_a_citation() {
|
||||||
|
let alice = Keys::generate();
|
||||||
|
let moderator = Keys::generate();
|
||||||
|
let peer = Keys::generate();
|
||||||
|
let group = group();
|
||||||
|
|
||||||
|
let message = read(
|
||||||
|
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
|
||||||
|
&group,
|
||||||
|
&alice,
|
||||||
|
Epoch(0),
|
||||||
|
);
|
||||||
|
let id = message.id;
|
||||||
|
let citation = AuthorityCitation {
|
||||||
|
entity: [0x33; 32],
|
||||||
|
version: 1,
|
||||||
|
hash: [0x44; 32],
|
||||||
|
};
|
||||||
|
|
||||||
|
let delete = |author: &Keys, citation: Option<&AuthorityCitation>| {
|
||||||
|
read(
|
||||||
|
&build_delete(
|
||||||
|
author.public_key(),
|
||||||
|
&channel(),
|
||||||
|
Epoch(0),
|
||||||
|
id,
|
||||||
|
Some(KIND_MESSAGE),
|
||||||
|
citation,
|
||||||
|
AT + 1_000,
|
||||||
|
),
|
||||||
|
&group,
|
||||||
|
author,
|
||||||
|
Epoch(0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let can_delete =
|
||||||
|
|actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| {
|
||||||
|
actor != author && citation.is_some() && actor == &moderator.public_key()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cited = vec![message.clone(), delete(&moderator, Some(&citation))];
|
||||||
|
assert!(matches!(
|
||||||
|
&cited[1].action,
|
||||||
|
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
fold(&cited, can_delete)[0].deleted,
|
||||||
|
"a cited moderator delete lands"
|
||||||
|
);
|
||||||
|
|
||||||
|
let uncited = vec![message.clone(), delete(&moderator, None)];
|
||||||
|
assert!(
|
||||||
|
!fold(&uncited, can_delete)[0].deleted,
|
||||||
|
"an uncited delete names no rank"
|
||||||
|
);
|
||||||
|
|
||||||
|
let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))];
|
||||||
|
assert!(
|
||||||
|
!fold(&peer_delete, can_delete)[0].deleted,
|
||||||
|
"a peer's delete is not authority"
|
||||||
|
);
|
||||||
|
|
||||||
|
let own = vec![message.clone(), delete(&alice, None)];
|
||||||
|
assert!(
|
||||||
|
fold(&own, |_, _, _| false)[0].deleted,
|
||||||
|
"a self-delete never consults the predicate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,979 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::derive::{
|
||||||
|
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
|
||||||
|
invite_links_locator, verify_community_id,
|
||||||
|
};
|
||||||
|
use crate::edition::{
|
||||||
|
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
|
||||||
|
build_edition, fold_head, parse_edition, vsk,
|
||||||
|
};
|
||||||
|
use crate::roles::{
|
||||||
|
AuthorityEdition, CommunityRoles, Grant, Permissions, Role, Roster, citation_ok, fold_roster,
|
||||||
|
};
|
||||||
|
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||||
|
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
|
||||||
|
|
||||||
|
pub const MAX_NAME_BYTES: usize = 64;
|
||||||
|
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
|
||||||
|
pub const MAX_RELAYS: usize = 5;
|
||||||
|
pub const MAX_REGISTRY_LINKS: usize = 64;
|
||||||
|
|
||||||
|
pub const GENERAL_CHANNEL: &str = "general";
|
||||||
|
pub const ROOT_EPOCH: Epoch = Epoch(0);
|
||||||
|
|
||||||
|
const GENESIS_VERSION: u64 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ImageRef {
|
||||||
|
pub url: String,
|
||||||
|
pub key: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub hash: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityMetadata {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub relays: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<ImageRef>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub banner: Option<ImageRef>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub custom: Option<Extra>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelMetadata {
|
||||||
|
pub name: String,
|
||||||
|
pub private: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub voice: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub custom: Option<Extra>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommunityIdentity {
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
pub owner: PublicKey,
|
||||||
|
pub owner_salt: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityIdentity {
|
||||||
|
pub fn verify(&self) -> bool {
|
||||||
|
verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommunityGenesis {
|
||||||
|
pub identity: CommunityIdentity,
|
||||||
|
pub community_root: [u8; 32],
|
||||||
|
pub control_root: [u8; 32],
|
||||||
|
pub channel_id: ChannelId,
|
||||||
|
pub wraps: Vec<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn genesis(
|
||||||
|
owner: &Keys,
|
||||||
|
metadata: &CommunityMetadata,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<CommunityGenesis> {
|
||||||
|
let metadata_content = encode_metadata(metadata)?;
|
||||||
|
let owner_salt = random_32()?;
|
||||||
|
|
||||||
|
let identity = CommunityIdentity {
|
||||||
|
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
||||||
|
owner: owner.public_key(),
|
||||||
|
owner_salt,
|
||||||
|
};
|
||||||
|
|
||||||
|
let community_root = random_32()?;
|
||||||
|
let control_root = random_32()?;
|
||||||
|
let channel_id = ChannelId::from_bytes(random_32()?);
|
||||||
|
|
||||||
|
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
|
||||||
|
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
|
||||||
|
|
||||||
|
let channel_content = serde_json::to_string(&ChannelMetadata {
|
||||||
|
name: GENERAL_CHANNEL.to_owned(),
|
||||||
|
private: false,
|
||||||
|
..ChannelMetadata::default()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let editions = [
|
||||||
|
build_edition(EditionFields {
|
||||||
|
author: identity.owner,
|
||||||
|
subkind: vsk::COMMUNITY_METADATA,
|
||||||
|
entity: *identity.community_id.as_bytes(),
|
||||||
|
version: GENESIS_VERSION,
|
||||||
|
prev: None,
|
||||||
|
citation: None,
|
||||||
|
content: &metadata_content,
|
||||||
|
at_secs,
|
||||||
|
}),
|
||||||
|
build_edition(EditionFields {
|
||||||
|
author: identity.owner,
|
||||||
|
subkind: vsk::CHANNEL_METADATA,
|
||||||
|
entity: *channel_id.as_bytes(),
|
||||||
|
version: GENESIS_VERSION,
|
||||||
|
prev: None,
|
||||||
|
citation: None,
|
||||||
|
content: &channel_content,
|
||||||
|
at_secs,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut wraps = Vec::with_capacity(editions.len());
|
||||||
|
|
||||||
|
for edition in &editions {
|
||||||
|
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CommunityGenesis {
|
||||||
|
identity,
|
||||||
|
community_root,
|
||||||
|
control_root,
|
||||||
|
channel_id,
|
||||||
|
wraps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a Control Plane wrap from its reading key alone.
|
||||||
|
pub fn open_edition(
|
||||||
|
wrap: &Event,
|
||||||
|
read: &GroupKey,
|
||||||
|
address: &PublicKey,
|
||||||
|
verify_wrap_signature: bool,
|
||||||
|
) -> Result<ParsedEdition> {
|
||||||
|
let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?;
|
||||||
|
|
||||||
|
if opened.seal_form != SealForm::Plaintext {
|
||||||
|
bail!("control editions require a plaintext seal");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parse_edition(&opened.rumor)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends editions to entity chains.
|
||||||
|
pub struct ControlWriter {
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub read: GroupKey,
|
||||||
|
pub signer: GroupKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Edition<'a> {
|
||||||
|
pub subkind: &'a str,
|
||||||
|
pub entity: [u8; 32],
|
||||||
|
pub content: &'a str,
|
||||||
|
/// The head this edition supersedes.
|
||||||
|
///
|
||||||
|
/// `None` starts the chain.
|
||||||
|
pub head: Option<&'a EntityHead>,
|
||||||
|
pub citation: Option<AuthorityCitation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlWriter {
|
||||||
|
pub fn publish(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
edition: Edition<'_>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let rumor = build_edition(EditionFields {
|
||||||
|
author: self.author,
|
||||||
|
subkind: edition.subkind,
|
||||||
|
entity: edition.entity,
|
||||||
|
version: edition
|
||||||
|
.head
|
||||||
|
.map_or(GENESIS_VERSION, |head| head.version + 1),
|
||||||
|
prev: edition.head.map(|head| head.self_hash),
|
||||||
|
citation: edition.citation,
|
||||||
|
content: edition.content,
|
||||||
|
at_secs,
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed = parse_edition(&rumor)?;
|
||||||
|
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
|
||||||
|
|
||||||
|
Ok((wrap, EntityHead::from(&parsed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_community_metadata(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
community_id: &CommunityId,
|
||||||
|
metadata: &CommunityMetadata,
|
||||||
|
head: Option<&EntityHead>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let content = encode_metadata(metadata)?;
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
keys,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::COMMUNITY_METADATA,
|
||||||
|
entity: *community_id.as_bytes(),
|
||||||
|
content: &content,
|
||||||
|
head,
|
||||||
|
citation,
|
||||||
|
},
|
||||||
|
at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_channel_metadata(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
channel: &ChannelId,
|
||||||
|
metadata: &ChannelMetadata,
|
||||||
|
head: Option<&EntityHead>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let content = serde_json::to_string(metadata)?;
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
keys,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::CHANNEL_METADATA,
|
||||||
|
entity: *channel.as_bytes(),
|
||||||
|
content: &content,
|
||||||
|
head,
|
||||||
|
citation,
|
||||||
|
},
|
||||||
|
at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_role(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
role: &Role,
|
||||||
|
head: Option<&EntityHead>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let content = role.to_content()?;
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
keys,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::ROLE,
|
||||||
|
entity: *role.role_id.as_bytes(),
|
||||||
|
content: &content,
|
||||||
|
head,
|
||||||
|
citation,
|
||||||
|
},
|
||||||
|
at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_grant(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
community_id: &CommunityId,
|
||||||
|
grant: &Grant,
|
||||||
|
head: Option<&EntityHead>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let content = grant.to_content()?;
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
keys,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::GRANT,
|
||||||
|
entity: grant_locator(community_id, &grant.member.to_bytes()),
|
||||||
|
content: &content,
|
||||||
|
head,
|
||||||
|
citation,
|
||||||
|
},
|
||||||
|
at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_banlist(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
community_id: &CommunityId,
|
||||||
|
banned: &BTreeSet<PublicKey>,
|
||||||
|
head: Option<&EntityHead>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let entries: Vec<String> = banned.iter().map(PublicKey::to_hex).collect();
|
||||||
|
let content = serde_json::to_string(&entries)?;
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
keys,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::BANLIST,
|
||||||
|
entity: banlist_locator(community_id),
|
||||||
|
content: &content,
|
||||||
|
head,
|
||||||
|
citation,
|
||||||
|
},
|
||||||
|
at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn set_registry(
|
||||||
|
&self,
|
||||||
|
keys: &Keys,
|
||||||
|
community_id: &CommunityId,
|
||||||
|
creator: &PublicKey,
|
||||||
|
links: &[PublicKey],
|
||||||
|
head: Option<&EntityHead>,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<(Event, EntityHead)> {
|
||||||
|
let entries: Vec<String> = links
|
||||||
|
.iter()
|
||||||
|
.take(MAX_REGISTRY_LINKS)
|
||||||
|
.map(PublicKey::to_hex)
|
||||||
|
.collect();
|
||||||
|
let content = serde_json::to_string(&entries)?;
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
keys,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::INVITE_LINKS,
|
||||||
|
entity: invite_links_locator(community_id, &creator.to_bytes()),
|
||||||
|
content: &content,
|
||||||
|
head,
|
||||||
|
citation,
|
||||||
|
},
|
||||||
|
at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
|
||||||
|
if metadata.name.len() > MAX_NAME_BYTES {
|
||||||
|
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
|
||||||
|
{
|
||||||
|
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut metadata = metadata.clone();
|
||||||
|
metadata.relays.truncate(MAX_RELAYS);
|
||||||
|
|
||||||
|
Ok(serde_json::to_string(&metadata)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ControlFold {
|
||||||
|
pub roles: CommunityRoles,
|
||||||
|
pub banned: BTreeSet<PublicKey>,
|
||||||
|
pub community: Option<CommunityMetadata>,
|
||||||
|
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||||
|
/// Each creator's live link-signer set.
|
||||||
|
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
|
||||||
|
pub floors: Floors,
|
||||||
|
pub gapped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlFold {
|
||||||
|
pub fn is_public(&self) -> bool {
|
||||||
|
self.registries.values().any(|links| !links.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fold_control(
|
||||||
|
owner: &PublicKey,
|
||||||
|
community_id: &CommunityId,
|
||||||
|
editions: &[ParsedEdition],
|
||||||
|
floors: &Floors,
|
||||||
|
held_bans: &BTreeSet<PublicKey>,
|
||||||
|
) -> ControlFold {
|
||||||
|
let authority: Vec<AuthorityEdition> = editions
|
||||||
|
.iter()
|
||||||
|
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
|
||||||
|
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
|
||||||
|
|
||||||
|
let mut floors = roster.floors;
|
||||||
|
floors.extend(metadata.floors);
|
||||||
|
|
||||||
|
ControlFold {
|
||||||
|
roles: roster.roles,
|
||||||
|
banned: roster.banned,
|
||||||
|
community: metadata.community,
|
||||||
|
channels: metadata.channels,
|
||||||
|
registries: metadata.registries,
|
||||||
|
floors,
|
||||||
|
gapped: roster.gapped || metadata.gapped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct MetadataFold {
|
||||||
|
community: Option<CommunityMetadata>,
|
||||||
|
channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||||
|
registries: BTreeMap<PublicKey, Vec<PublicKey>>,
|
||||||
|
floors: Floors,
|
||||||
|
gapped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_metadata(
|
||||||
|
owner: &PublicKey,
|
||||||
|
community_id: &CommunityId,
|
||||||
|
editions: &[ParsedEdition],
|
||||||
|
roster: &Roster,
|
||||||
|
floors: &Floors,
|
||||||
|
) -> MetadataFold {
|
||||||
|
let judge = Judge {
|
||||||
|
owner,
|
||||||
|
community_id,
|
||||||
|
roster,
|
||||||
|
floors,
|
||||||
|
};
|
||||||
|
let community_entity = *community_id.as_bytes();
|
||||||
|
let mut community: Vec<&ParsedEdition> = Vec::new();
|
||||||
|
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
|
||||||
|
|
||||||
|
for edition in editions {
|
||||||
|
match edition.subkind.as_str() {
|
||||||
|
// A channel addressed at the community's own coordinate would share, and
|
||||||
|
// corrupt, the metadata chain's floor.
|
||||||
|
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
|
||||||
|
community.push(edition)
|
||||||
|
}
|
||||||
|
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
|
||||||
|
channels.entry(edition.entity).or_default().push(edition);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fold = MetadataFold::default();
|
||||||
|
|
||||||
|
if let Some(head) = authorized_head(
|
||||||
|
&judge,
|
||||||
|
community_entity,
|
||||||
|
&community,
|
||||||
|
Permissions::MANAGE_METADATA,
|
||||||
|
&mut fold.gapped,
|
||||||
|
) {
|
||||||
|
fold.community = serde_json::from_str(&head.content).ok();
|
||||||
|
fold.floors.insert(head.entity, EntityHead::from(head));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entity, candidates) in &channels {
|
||||||
|
let Some(head) = authorized_head(
|
||||||
|
&judge,
|
||||||
|
*entity,
|
||||||
|
candidates,
|
||||||
|
Permissions::MANAGE_CHANNELS,
|
||||||
|
&mut fold.gapped,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
fold.floors.insert(*entity, EntityHead::from(head));
|
||||||
|
|
||||||
|
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
|
||||||
|
fold.channels
|
||||||
|
.insert(ChannelId::from_bytes(*entity), metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped);
|
||||||
|
|
||||||
|
fold
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_registries(
|
||||||
|
judge: &Judge<'_>,
|
||||||
|
editions: &[ParsedEdition],
|
||||||
|
floors: &mut Floors,
|
||||||
|
gapped: &mut bool,
|
||||||
|
) -> BTreeMap<PublicKey, Vec<PublicKey>> {
|
||||||
|
let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
|
||||||
|
|
||||||
|
for edition in editions {
|
||||||
|
if edition.subkind == vsk::INVITE_LINKS
|
||||||
|
&& invite_links_locator(judge.community_id, &edition.author.to_bytes())
|
||||||
|
== edition.entity
|
||||||
|
{
|
||||||
|
candidates.entry(edition.entity).or_default().push(edition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut registries = BTreeMap::new();
|
||||||
|
|
||||||
|
for (entity, group) in &candidates {
|
||||||
|
let Some(head) = authorized_head(judge, *entity, group, Permissions::CREATE_INVITE, gapped)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
floors.insert(*entity, EntityHead::from(head));
|
||||||
|
|
||||||
|
let Ok(links) = serde_json::from_str::<Vec<String>>(&head.content) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
registries.insert(
|
||||||
|
head.author,
|
||||||
|
links
|
||||||
|
.iter()
|
||||||
|
.filter_map(|link| PublicKey::from_hex(link).ok())
|
||||||
|
.take(MAX_REGISTRY_LINKS)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
registries
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Judge<'a> {
|
||||||
|
owner: &'a PublicKey,
|
||||||
|
community_id: &'a CommunityId,
|
||||||
|
roster: &'a Roster,
|
||||||
|
floors: &'a Floors,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorized_head<'a>(
|
||||||
|
judge: &Judge<'_>,
|
||||||
|
entity: [u8; 32],
|
||||||
|
candidates: &[&'a ParsedEdition],
|
||||||
|
permission: u64,
|
||||||
|
gapped: &mut bool,
|
||||||
|
) -> Option<&'a ParsedEdition> {
|
||||||
|
let authorized: Vec<&ParsedEdition> = candidates
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|edition| {
|
||||||
|
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
|
||||||
|
!judge.roster.banned.contains(&edition.author)
|
||||||
|
&& judge
|
||||||
|
.roster
|
||||||
|
.roles
|
||||||
|
.is_authorized(&edition.author, judge.owner, permission)
|
||||||
|
&& citation_ok(
|
||||||
|
judge.owner,
|
||||||
|
judge.community_id,
|
||||||
|
&edition.author,
|
||||||
|
edition.citation.as_ref(),
|
||||||
|
&judge.roster.floors,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if authorized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metas: Vec<EditionMeta> = authorized
|
||||||
|
.iter()
|
||||||
|
.map(|edition| EditionMeta::from(*edition))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selection = fold_head(&metas, judge.floors.get(&entity));
|
||||||
|
*gapped |= selection.gap;
|
||||||
|
|
||||||
|
selection.head.map(|index| authorized[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seal_edition(
|
||||||
|
edition: &UnsignedEvent,
|
||||||
|
owner: &Keys,
|
||||||
|
read: &GroupKey,
|
||||||
|
signer: &GroupKey,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> Result<Event> {
|
||||||
|
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
||||||
|
|
||||||
|
let (wrap, _) = wrap_seal_with(
|
||||||
|
&seal,
|
||||||
|
read.conversation(),
|
||||||
|
signer.keys(),
|
||||||
|
KIND_WRAP,
|
||||||
|
Timestamp::from_secs(at_secs),
|
||||||
|
&[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use nostr_memory::MemoryDatabase;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::derive::grant_locator;
|
||||||
|
use crate::edition::fold;
|
||||||
|
use crate::roles::{Grant, Role, RoleScope};
|
||||||
|
use crate::store::{CommunityState, load_state, save_state};
|
||||||
|
use crate::{Extra, RoleId};
|
||||||
|
|
||||||
|
const AT: u64 = 1_700_000_000;
|
||||||
|
|
||||||
|
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
|
||||||
|
let community_id = minted.identity.community_id;
|
||||||
|
|
||||||
|
(
|
||||||
|
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
|
||||||
|
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
|
||||||
|
.expect("derives"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
|
||||||
|
wraps
|
||||||
|
.iter()
|
||||||
|
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(name: &str) -> CommunityMetadata {
|
||||||
|
CommunityMetadata {
|
||||||
|
name: name.to_owned(),
|
||||||
|
..CommunityMetadata::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn genesis_reopens_for_a_second_holder() {
|
||||||
|
let owner = Keys::generate();
|
||||||
|
let community_metadata = CommunityMetadata {
|
||||||
|
name: "coop".to_owned(),
|
||||||
|
relays: vec!["wss://relay.example".to_owned()],
|
||||||
|
..CommunityMetadata::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
|
||||||
|
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||||
|
|
||||||
|
// Only what an invite hands over: the roots, the community id and the owner salt.
|
||||||
|
let (read, signer) = holder(&minted);
|
||||||
|
let editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||||
|
|
||||||
|
assert_eq!(editions.len(), 2);
|
||||||
|
|
||||||
|
let community = &editions[0];
|
||||||
|
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
|
||||||
|
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
|
||||||
|
assert_eq!(community.author, owner.public_key());
|
||||||
|
assert_eq!((community.version, community.prev), (1, None));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<CommunityMetadata>(&community.content)
|
||||||
|
.expect("parses")
|
||||||
|
.name,
|
||||||
|
"coop"
|
||||||
|
);
|
||||||
|
|
||||||
|
let channel = &editions[1];
|
||||||
|
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
|
||||||
|
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
|
||||||
|
|
||||||
|
for edition in &editions {
|
||||||
|
let folded = fold(&[EditionMeta::from(edition)], 0, None);
|
||||||
|
assert_eq!(folded.head, Some(0));
|
||||||
|
assert!(
|
||||||
|
folded.anchored && !folded.gap,
|
||||||
|
"genesis anchors at its floor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
|
||||||
|
|
||||||
|
smol::block_on(async {
|
||||||
|
let database = MemoryDatabase::unbounded();
|
||||||
|
save_state(&database, &state).await.expect("saves");
|
||||||
|
let loaded = load_state(&database, &minted.identity.community_id)
|
||||||
|
.await
|
||||||
|
.expect("loads")
|
||||||
|
.expect("present");
|
||||||
|
|
||||||
|
assert_eq!(loaded.community_root, minted.community_root);
|
||||||
|
assert_eq!(loaded.control_root, Some(minted.control_root));
|
||||||
|
assert_eq!(loaded.channels.len(), 1);
|
||||||
|
assert_eq!(loaded.heads.len(), 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||||
|
let owner = Keys::generate();
|
||||||
|
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||||
|
let community_id = minted.identity.community_id;
|
||||||
|
let owner_pk = owner.public_key();
|
||||||
|
let (read, signer) = holder(&minted);
|
||||||
|
|
||||||
|
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||||
|
let roster = fold_control(
|
||||||
|
&owner_pk,
|
||||||
|
&community_id,
|
||||||
|
&genesis_editions,
|
||||||
|
&Floors::new(),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
roster.community.as_ref().map(|meta| meta.name.as_str()),
|
||||||
|
Some("coop")
|
||||||
|
);
|
||||||
|
|
||||||
|
let writer = ControlWriter {
|
||||||
|
author: owner_pk,
|
||||||
|
read: read.clone(),
|
||||||
|
signer: signer.clone(),
|
||||||
|
};
|
||||||
|
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
|
||||||
|
let channel_head = roster
|
||||||
|
.floors
|
||||||
|
.get(minted.channel_id.as_bytes())
|
||||||
|
.expect("head");
|
||||||
|
|
||||||
|
let (community_wrap, _) = writer
|
||||||
|
.set_community_metadata(
|
||||||
|
&owner,
|
||||||
|
&community_id,
|
||||||
|
&CommunityMetadata {
|
||||||
|
relays: vec!["wss://relay.example".to_owned()],
|
||||||
|
..metadata("coop two")
|
||||||
|
},
|
||||||
|
Some(community_head),
|
||||||
|
None,
|
||||||
|
AT + 1,
|
||||||
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
let (channel_wrap, _) = writer
|
||||||
|
.set_channel_metadata(
|
||||||
|
&owner,
|
||||||
|
&minted.channel_id,
|
||||||
|
&ChannelMetadata {
|
||||||
|
name: "lobby".to_owned(),
|
||||||
|
private: false,
|
||||||
|
..ChannelMetadata::default()
|
||||||
|
},
|
||||||
|
Some(channel_head),
|
||||||
|
None,
|
||||||
|
AT + 2,
|
||||||
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
|
||||||
|
let mut edited = genesis_editions.clone();
|
||||||
|
edited.extend(open_all(
|
||||||
|
&[community_wrap, channel_wrap],
|
||||||
|
&read,
|
||||||
|
&signer.pk(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let folded = fold_control(
|
||||||
|
&owner_pk,
|
||||||
|
&community_id,
|
||||||
|
&edited,
|
||||||
|
&Floors::new(),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||||
|
Some("coop two")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
folded
|
||||||
|
.channels
|
||||||
|
.get(&minted.channel_id)
|
||||||
|
.map(|channel| channel.name.as_str()),
|
||||||
|
Some("lobby")
|
||||||
|
);
|
||||||
|
|
||||||
|
// A relay serving only the editions a client already folded past must not walk
|
||||||
|
// the community backwards.
|
||||||
|
let stale = fold_control(
|
||||||
|
&owner_pk,
|
||||||
|
&community_id,
|
||||||
|
&genesis_editions,
|
||||||
|
&folded.floors,
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
assert!(stale.community.is_none());
|
||||||
|
assert!(stale.channels.is_empty());
|
||||||
|
|
||||||
|
let mut state =
|
||||||
|
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
|
||||||
|
state.apply_fold(&folded);
|
||||||
|
assert_eq!(state.channels.len(), 1);
|
||||||
|
assert_eq!(state.channels[0].name, "lobby");
|
||||||
|
assert_eq!(state.relays.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
||||||
|
let owner = Keys::generate();
|
||||||
|
let member = Keys::generate();
|
||||||
|
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||||
|
let community_id = minted.identity.community_id;
|
||||||
|
let owner_pk = owner.public_key();
|
||||||
|
let (read, signer) = holder(&minted);
|
||||||
|
|
||||||
|
let writer = ControlWriter {
|
||||||
|
author: owner_pk,
|
||||||
|
read: read.clone(),
|
||||||
|
signer: signer.clone(),
|
||||||
|
};
|
||||||
|
let role_id = RoleId::from_bytes([0x07; 32]);
|
||||||
|
let role = Role {
|
||||||
|
role_id,
|
||||||
|
name: "Mod".to_owned(),
|
||||||
|
position: 1,
|
||||||
|
permissions: Permissions(Permissions::MANAGE_METADATA),
|
||||||
|
scope: RoleScope::Server,
|
||||||
|
color: 0,
|
||||||
|
extra: Extra::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (role_wrap, _) = writer
|
||||||
|
.publish(
|
||||||
|
&owner,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::ROLE,
|
||||||
|
entity: *role_id.as_bytes(),
|
||||||
|
content: &role.to_content().expect("serializes"),
|
||||||
|
head: None,
|
||||||
|
citation: None,
|
||||||
|
},
|
||||||
|
AT + 1,
|
||||||
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
let (grant_wrap, _) = writer
|
||||||
|
.publish(
|
||||||
|
&owner,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::GRANT,
|
||||||
|
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
|
||||||
|
content: &Grant {
|
||||||
|
member: member.public_key(),
|
||||||
|
role_ids: vec![role_id],
|
||||||
|
control_wrap: None,
|
||||||
|
extra: Extra::default(),
|
||||||
|
}
|
||||||
|
.to_content()
|
||||||
|
.expect("serializes"),
|
||||||
|
head: None,
|
||||||
|
citation: None,
|
||||||
|
},
|
||||||
|
AT + 2,
|
||||||
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
|
||||||
|
let mut base = open_all(&minted.wraps, &read, &signer.pk());
|
||||||
|
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
|
||||||
|
|
||||||
|
let roster = fold_control(
|
||||||
|
&owner_pk,
|
||||||
|
&community_id,
|
||||||
|
&base,
|
||||||
|
&Floors::new(),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
|
||||||
|
|
||||||
|
let grant = roster
|
||||||
|
.floors
|
||||||
|
.get(&grant_locator(
|
||||||
|
&community_id,
|
||||||
|
&member.public_key().to_bytes(),
|
||||||
|
))
|
||||||
|
.expect("the member's grant folded");
|
||||||
|
let head = roster.floors.get(community_id.as_bytes()).expect("head");
|
||||||
|
|
||||||
|
// The member seals with their own keys and wraps with the staff write key.
|
||||||
|
let member_writer = ControlWriter {
|
||||||
|
author: member.public_key(),
|
||||||
|
read,
|
||||||
|
signer: signer.clone(),
|
||||||
|
};
|
||||||
|
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
|
||||||
|
|
||||||
|
let (uncited, _) = member_writer
|
||||||
|
.publish(
|
||||||
|
&member,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::COMMUNITY_METADATA,
|
||||||
|
entity: *community_id.as_bytes(),
|
||||||
|
content: &content,
|
||||||
|
head: Some(head),
|
||||||
|
citation: None,
|
||||||
|
},
|
||||||
|
AT + 3,
|
||||||
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
let (cited, _) = member_writer
|
||||||
|
.publish(
|
||||||
|
&member,
|
||||||
|
Edition {
|
||||||
|
subkind: vsk::COMMUNITY_METADATA,
|
||||||
|
entity: *community_id.as_bytes(),
|
||||||
|
content: &content,
|
||||||
|
head: Some(head),
|
||||||
|
citation: Some(AuthorityCitation {
|
||||||
|
entity: grant.entity,
|
||||||
|
version: grant.version,
|
||||||
|
hash: grant.self_hash,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
AT + 4,
|
||||||
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
|
||||||
|
// Uncited, the edit claims an authority the member never showed.
|
||||||
|
let mut forged = base.clone();
|
||||||
|
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
|
||||||
|
let folded = fold_control(
|
||||||
|
&owner_pk,
|
||||||
|
&community_id,
|
||||||
|
&forged,
|
||||||
|
&Floors::new(),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||||
|
Some("coop")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut edited_editions = base;
|
||||||
|
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
|
||||||
|
let folded = fold_control(
|
||||||
|
&owner_pk,
|
||||||
|
&community_id,
|
||||||
|
&edited_editions,
|
||||||
|
&Floors::new(),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
folded.community.as_ref().map(|meta| meta.name.as_str()),
|
||||||
|
Some("coop by mod")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, Mutex, PoisonError};
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
|
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::{ChannelId, CommunityId, Epoch};
|
||||||
|
|
||||||
|
pub const TOKEN_LEN: usize = 16;
|
||||||
|
|
||||||
|
const LABEL_CHANNEL: &str = "concord/channel";
|
||||||
|
const LABEL_CONTROL: &str = "concord/control";
|
||||||
|
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
|
||||||
|
const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
|
||||||
|
const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
|
||||||
|
const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
|
||||||
|
const LABEL_GUESTBOOK: &str = "concord/guestbook";
|
||||||
|
const LABEL_DISSOLVED: &str = "concord/dissolved";
|
||||||
|
const LABEL_GRANT: &str = "concord/grant";
|
||||||
|
const LABEL_BANLIST: &str = "concord/banlist";
|
||||||
|
const LABEL_PINS: &str = "concord/pins";
|
||||||
|
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
|
||||||
|
const LABEL_INVITE_KEY: &str = "concord/invite-key";
|
||||||
|
|
||||||
|
const LABEL_COMMUNITY: &str = "concord/community";
|
||||||
|
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
|
||||||
|
|
||||||
|
const ZERO32: [u8; 32] = [0u8; 32];
|
||||||
|
|
||||||
|
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
|
||||||
|
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
|
||||||
|
info.extend_from_slice(label.as_bytes());
|
||||||
|
info.push(0x00);
|
||||||
|
info.extend_from_slice(id32);
|
||||||
|
|
||||||
|
if let Some(epoch) = epoch {
|
||||||
|
info.extend_from_slice(&epoch.to_be_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
||||||
|
let mut okm = [0u8; 32];
|
||||||
|
Hkdf::<Sha256>::new(None, ikm)
|
||||||
|
.expand(info, &mut okm)
|
||||||
|
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
|
||||||
|
okm
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
|
||||||
|
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
|
||||||
|
return Ok(secret_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for counter in 0u8..=u8::MAX {
|
||||||
|
let mut info = Vec::with_capacity(base_info.len() + 1);
|
||||||
|
info.extend_from_slice(base_info);
|
||||||
|
info.push(counter);
|
||||||
|
|
||||||
|
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
|
||||||
|
return Ok(secret_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupKey {
|
||||||
|
keys: Keys,
|
||||||
|
conversation: ConversationKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupKey {
|
||||||
|
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
|
||||||
|
let key = memo_key(label, secret, id32, epoch);
|
||||||
|
|
||||||
|
if let Some(hit) = lock_memo().get(&key) {
|
||||||
|
return Ok(hit.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = build_info(label, id32, epoch);
|
||||||
|
let secret_key = hkdf_to_secret_key(secret, &info)?;
|
||||||
|
let keys = Keys::new(secret_key);
|
||||||
|
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
||||||
|
let group_key = Self { keys, conversation };
|
||||||
|
|
||||||
|
let mut memo = lock_memo();
|
||||||
|
|
||||||
|
if memo.len() >= 1024 {
|
||||||
|
memo.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
memo.insert(key, group_key.clone());
|
||||||
|
|
||||||
|
Ok(group_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pk(&self) -> PublicKey {
|
||||||
|
self.keys.public_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pk_hex(&self) -> String {
|
||||||
|
self.keys.public_key().to_hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keys(&self) -> &Keys {
|
||||||
|
&self.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conversation(&self) -> &ConversationKey {
|
||||||
|
&self.conversation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for GroupKey {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("GroupKey")
|
||||||
|
.field("pk", &self.pk_hex())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
|
||||||
|
|
||||||
|
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
|
||||||
|
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_memo() {
|
||||||
|
lock_memo().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(label.as_bytes());
|
||||||
|
hasher.update([0x00]);
|
||||||
|
hasher.update(secret);
|
||||||
|
hasher.update(id32);
|
||||||
|
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
|
||||||
|
hasher.update([epoch.is_some() as u8]);
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `secret` is the `community_root` for a public channel.
|
||||||
|
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plane's read key: its conversation key encrypts the wraps for every member.
|
||||||
|
pub fn control_group_key(
|
||||||
|
community_root: &[u8; 32],
|
||||||
|
community_id: &CommunityId,
|
||||||
|
epoch: Epoch,
|
||||||
|
) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(
|
||||||
|
LABEL_CONTROL,
|
||||||
|
community_root,
|
||||||
|
community_id.as_bytes(),
|
||||||
|
Some(epoch.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plane's address and wrap signer, held only by staff.
|
||||||
|
/// Wraps still encrypt under [`control_group_key`].
|
||||||
|
pub fn control_signer_group_key(
|
||||||
|
control_root: &[u8; 32],
|
||||||
|
community_id: &CommunityId,
|
||||||
|
epoch: Epoch,
|
||||||
|
) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(
|
||||||
|
LABEL_CONTROL_SIGNER,
|
||||||
|
control_root,
|
||||||
|
community_id.as_bytes(),
|
||||||
|
Some(epoch.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member-writable, unlike the Control Plane:
|
||||||
|
///
|
||||||
|
/// - A join or a leave is each member's own word.
|
||||||
|
pub fn guestbook_group_key(
|
||||||
|
community_root: &[u8; 32],
|
||||||
|
community_id: &CommunityId,
|
||||||
|
epoch: Epoch,
|
||||||
|
) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(
|
||||||
|
LABEL_GUESTBOOK,
|
||||||
|
community_root,
|
||||||
|
community_id.as_bytes(),
|
||||||
|
Some(epoch.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyed by the prior `community_root` rather than the channel key,
|
||||||
|
/// so any retained member recovers any epoch's rekey without a ratchet.
|
||||||
|
pub fn channel_rekey_group_key(
|
||||||
|
prior_root: &[u8; 32],
|
||||||
|
channel: &ChannelId,
|
||||||
|
new_epoch: Epoch,
|
||||||
|
) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(
|
||||||
|
LABEL_REKEY_PSEUDONYM,
|
||||||
|
prior_root,
|
||||||
|
channel.as_bytes(),
|
||||||
|
Some(new_epoch.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_rekey_group_key(
|
||||||
|
prior_root: &[u8; 32],
|
||||||
|
community_id: &CommunityId,
|
||||||
|
new_epoch: Epoch,
|
||||||
|
) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(
|
||||||
|
LABEL_BASE_REKEY_PSEUDONYM,
|
||||||
|
prior_root,
|
||||||
|
community_id.as_bytes(),
|
||||||
|
Some(new_epoch.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
|
||||||
|
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(LABEL_COMMUNITY.as_bytes());
|
||||||
|
hasher.update(owner_xonly);
|
||||||
|
hasher.update(owner_salt);
|
||||||
|
CommunityId::from_bytes(hasher.finalize().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_community_id(
|
||||||
|
community_id: &CommunityId,
|
||||||
|
owner_xonly: &[u8; 32],
|
||||||
|
owner_salt: &[u8; 32],
|
||||||
|
) -> bool {
|
||||||
|
community_id_of(owner_xonly, owner_salt) == *community_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The continuity a rekey blob must satisfy against the key currently held.
|
||||||
|
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
|
||||||
|
hasher.update(previous_epoch.0.to_be_bytes());
|
||||||
|
hasher.update(previous_key);
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
|
||||||
|
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
|
||||||
|
hkdf32(
|
||||||
|
community_id.as_bytes(),
|
||||||
|
&build_info(LABEL_GRANT, member_xonly, None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
|
||||||
|
hkdf32(
|
||||||
|
community_id.as_bytes(),
|
||||||
|
&build_info(LABEL_BANLIST, &ZERO32, None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
|
||||||
|
hkdf32(
|
||||||
|
community_id.as_bytes(),
|
||||||
|
&build_info(LABEL_PINS, channel.as_bytes(), None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound to the creator, so each creator owns exactly their own registry.
|
||||||
|
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
|
||||||
|
hkdf32(
|
||||||
|
community_id.as_bytes(),
|
||||||
|
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built from public inputs only, so a locator match proves nothing about authenticity
|
||||||
|
pub fn recipient_locator(
|
||||||
|
rotator_xonly: &[u8; 32],
|
||||||
|
recipient_xonly: &[u8; 32],
|
||||||
|
scope_id: &[u8; 32],
|
||||||
|
new_epoch: Epoch,
|
||||||
|
) -> [u8; 32] {
|
||||||
|
let mut ikm = [0u8; 64];
|
||||||
|
ikm[..32].copy_from_slice(rotator_xonly);
|
||||||
|
ikm[32..].copy_from_slice(recipient_xonly);
|
||||||
|
hkdf32(
|
||||||
|
&ikm,
|
||||||
|
&build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
|
||||||
|
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
|
||||||
|
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const CHANNEL_E0_SEED: &str =
|
||||||
|
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
|
||||||
|
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
|
||||||
|
const CHANNEL_EMULTI_PK: &str =
|
||||||
|
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
|
||||||
|
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
|
||||||
|
const CONTROL_SIGNER_E0_SEED: &str =
|
||||||
|
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
|
||||||
|
const CONTROL_SIGNER_E0_PK: &str =
|
||||||
|
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
|
||||||
|
const CONTROL_SIGNER_EMULTI_PK: &str =
|
||||||
|
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
|
||||||
|
const GUESTBOOK_E0_PK: &str =
|
||||||
|
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
|
||||||
|
const CHANNEL_REKEY_E1_PK: &str =
|
||||||
|
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
|
||||||
|
const BASE_REKEY_E1_PK: &str =
|
||||||
|
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
|
||||||
|
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
|
||||||
|
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
|
||||||
|
const BANLIST_LOCATOR: &str =
|
||||||
|
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
|
||||||
|
const INVITE_LINKS_LOCATOR: &str =
|
||||||
|
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
|
||||||
|
const RECIPIENT_LOCATOR: &str =
|
||||||
|
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
|
||||||
|
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
|
||||||
|
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
|
||||||
|
const EPOCH_COMMITMENT: &str =
|
||||||
|
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
|
||||||
|
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
|
||||||
|
const EPOCH_MULTI: u64 = 0x0102030405060708;
|
||||||
|
|
||||||
|
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
|
||||||
|
fn secret() -> [u8; 32] {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
for (index, byte) in key.iter_mut().enumerate() {
|
||||||
|
*byte = index as u8;
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id32() -> [u8; 32] {
|
||||||
|
let mut id = [0u8; 32];
|
||||||
|
for (index, byte) in id.iter_mut().enumerate() {
|
||||||
|
*byte = 255 - index as u8;
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
data_encoding::HEXLOWER.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_vectors() {
|
||||||
|
let secret = secret();
|
||||||
|
let id = id32();
|
||||||
|
let alt = [0x11u8; 32];
|
||||||
|
let community_id = CommunityId::from_bytes(id);
|
||||||
|
let channel = ChannelId::from_bytes(id);
|
||||||
|
|
||||||
|
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
|
||||||
|
assert_eq!(
|
||||||
|
hex(channel_e0.keys().secret_key().as_secret_bytes()),
|
||||||
|
CHANNEL_E0_SEED
|
||||||
|
);
|
||||||
|
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
|
||||||
|
assert_eq!(
|
||||||
|
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
CHANNEL_EMULTI_PK
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
control_group_key(&secret, &community_id, Epoch(0))
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
CONTROL_E0_PK
|
||||||
|
);
|
||||||
|
|
||||||
|
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
|
||||||
|
assert_eq!(
|
||||||
|
hex(signer.keys().secret_key().as_secret_bytes()),
|
||||||
|
CONTROL_SIGNER_E0_SEED
|
||||||
|
);
|
||||||
|
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
|
||||||
|
assert_eq!(
|
||||||
|
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
CONTROL_SIGNER_EMULTI_PK
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
guestbook_group_key(&secret, &community_id, Epoch(0))
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
GUESTBOOK_E0_PK
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
channel_rekey_group_key(&secret, &channel, Epoch(1))
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
CHANNEL_REKEY_E1_PK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base_rekey_group_key(&secret, &community_id, Epoch(1))
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
BASE_REKEY_E1_PK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dissolved_group_key(&community_id)
|
||||||
|
.expect("derives")
|
||||||
|
.pk_hex(),
|
||||||
|
DISSOLVED_PK
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
|
||||||
|
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
|
||||||
|
assert_eq!(
|
||||||
|
hex(&invite_links_locator(&community_id, &alt)),
|
||||||
|
INVITE_LINKS_LOCATOR
|
||||||
|
);
|
||||||
|
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
|
||||||
|
assert_eq!(
|
||||||
|
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
|
||||||
|
RECIPIENT_LOCATOR
|
||||||
|
);
|
||||||
|
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
|
||||||
|
|
||||||
|
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
|
||||||
|
assert_eq!(
|
||||||
|
hex(&epoch_key_commitment(Epoch(2), &secret)),
|
||||||
|
EPOCH_COMMITMENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::decode_hex_32;
|
||||||
|
use crate::stream::build_rumor_secs;
|
||||||
|
|
||||||
|
pub const KIND_CONTROL: u16 = 3308;
|
||||||
|
|
||||||
|
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
|
||||||
|
|
||||||
|
/// Entity types an edition can address.
|
||||||
|
pub mod vsk {
|
||||||
|
pub const COMMUNITY_METADATA: &str = "0";
|
||||||
|
pub const ROLE: &str = "1";
|
||||||
|
pub const CHANNEL_METADATA: &str = "2";
|
||||||
|
pub const GRANT: &str = "3";
|
||||||
|
pub const BANLIST: &str = "4";
|
||||||
|
pub const INVITE_LIVE: &str = "6";
|
||||||
|
pub const INVITE_LINKS: &str = "8";
|
||||||
|
pub const INVITE_REVOKED: &str = "9";
|
||||||
|
pub const DISSOLVED: &str = "10";
|
||||||
|
pub const PINS: &str = "11";
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const TAG_SUBKIND: &str = "vsk";
|
||||||
|
pub const TAG_CITATION: &str = "vac";
|
||||||
|
|
||||||
|
const TAG_ENTITY: &str = "eid";
|
||||||
|
const TAG_VERSION: &str = "ev";
|
||||||
|
const TAG_PREV: &str = "ep";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum EditionError {
|
||||||
|
BadKind(u16),
|
||||||
|
BadField(&'static str),
|
||||||
|
Duplicate(&'static str),
|
||||||
|
Missing(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EditionError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
|
||||||
|
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
|
||||||
|
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
|
||||||
|
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for EditionError {}
|
||||||
|
|
||||||
|
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
|
||||||
|
/// coordinate, version and hash. It is a sync floor, not the verdict.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AuthorityCitation {
|
||||||
|
pub entity: [u8; 32],
|
||||||
|
pub version: u64,
|
||||||
|
pub hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParsedEdition {
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub subkind: String,
|
||||||
|
pub entity: [u8; 32],
|
||||||
|
pub version: u64,
|
||||||
|
pub prev: Option<[u8; 32]>,
|
||||||
|
pub citation: Option<AuthorityCitation>,
|
||||||
|
pub content: String,
|
||||||
|
pub self_hash: [u8; 32],
|
||||||
|
pub rumor_id: EventId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EditionFields<'a> {
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub subkind: &'a str,
|
||||||
|
pub entity: [u8; 32],
|
||||||
|
pub version: u64,
|
||||||
|
pub prev: Option<[u8; 32]>,
|
||||||
|
pub citation: Option<AuthorityCitation>,
|
||||||
|
pub content: &'a str,
|
||||||
|
pub at_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signing_bytes(
|
||||||
|
entity: &[u8; 32],
|
||||||
|
version: u64,
|
||||||
|
prev: Option<&[u8; 32]>,
|
||||||
|
content: &[u8],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut bytes =
|
||||||
|
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
|
||||||
|
|
||||||
|
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
|
||||||
|
bytes.extend_from_slice(EDITION_LABEL);
|
||||||
|
bytes.extend_from_slice(entity);
|
||||||
|
bytes.extend_from_slice(&version.to_be_bytes());
|
||||||
|
|
||||||
|
match prev {
|
||||||
|
Some(prev) => {
|
||||||
|
bytes.push(1);
|
||||||
|
bytes.extend_from_slice(prev);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
bytes.push(0);
|
||||||
|
bytes.extend_from_slice(&[0u8; 32]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
|
||||||
|
bytes.extend_from_slice(content);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edition_hash(
|
||||||
|
entity: &[u8; 32],
|
||||||
|
version: u64,
|
||||||
|
prev: Option<&[u8; 32]>,
|
||||||
|
content: &[u8],
|
||||||
|
) -> [u8; 32] {
|
||||||
|
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
|
||||||
|
Tag::custom(
|
||||||
|
TAG_CITATION,
|
||||||
|
[
|
||||||
|
HEXLOWER.encode(&citation.entity),
|
||||||
|
citation.version.to_string(),
|
||||||
|
HEXLOWER.encode(&citation.hash),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
|
||||||
|
if fields.len() != 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(AuthorityCitation {
|
||||||
|
entity: hex32(&fields[1], TAG_CITATION).ok()?,
|
||||||
|
version: canonical_decimal(&fields[2])?,
|
||||||
|
hash: hex32(&fields[3], TAG_CITATION).ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
|
||||||
|
let mut tags = vec![
|
||||||
|
Tag::custom(TAG_SUBKIND, [fields.subkind]),
|
||||||
|
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
|
||||||
|
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(prev) = fields.prev {
|
||||||
|
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(citation) = fields.citation {
|
||||||
|
tags.push(citation_tag(&citation));
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rumor_secs(
|
||||||
|
KIND_CONTROL,
|
||||||
|
fields.author,
|
||||||
|
fields.content,
|
||||||
|
tags,
|
||||||
|
fields.at_secs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
|
||||||
|
let kind = rumor.kind.as_u16();
|
||||||
|
|
||||||
|
if kind != KIND_CONTROL {
|
||||||
|
return Err(EditionError::BadKind(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
let subkind = value(rumor, TAG_SUBKIND)?
|
||||||
|
.ok_or(EditionError::Missing(TAG_SUBKIND))?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
if canonical_decimal(&subkind).is_none() {
|
||||||
|
return Err(EditionError::BadField(TAG_SUBKIND));
|
||||||
|
}
|
||||||
|
|
||||||
|
let entity = hex32(
|
||||||
|
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
|
||||||
|
TAG_ENTITY,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let version =
|
||||||
|
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
|
||||||
|
.ok_or(EditionError::BadField(TAG_VERSION))?;
|
||||||
|
|
||||||
|
let prev = match value(rumor, TAG_PREV)? {
|
||||||
|
Some(raw) => Some(hex32(raw, TAG_PREV)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let citation = match fields(rumor, TAG_CITATION)? {
|
||||||
|
Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
|
||||||
|
|
||||||
|
Ok(ParsedEdition {
|
||||||
|
author: rumor.pubkey,
|
||||||
|
subkind,
|
||||||
|
entity,
|
||||||
|
version,
|
||||||
|
prev,
|
||||||
|
citation,
|
||||||
|
content: rumor.content.clone(),
|
||||||
|
self_hash,
|
||||||
|
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct EditionMeta {
|
||||||
|
pub version: u64,
|
||||||
|
pub self_hash: [u8; 32],
|
||||||
|
pub prev: Option<[u8; 32]>,
|
||||||
|
pub tiebreak_id: EventId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ParsedEdition> for EditionMeta {
|
||||||
|
fn from(edition: &ParsedEdition) -> Self {
|
||||||
|
Self {
|
||||||
|
version: edition.version,
|
||||||
|
self_hash: edition.self_hash,
|
||||||
|
prev: edition.prev,
|
||||||
|
tiebreak_id: edition.rumor_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct FoldResult {
|
||||||
|
pub head: Option<usize>,
|
||||||
|
pub gap: bool,
|
||||||
|
pub anchored: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highest version whose chain is intact, given a held floor.
|
||||||
|
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||||
|
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||||
|
|
||||||
|
for (index, edition) in editions.iter().enumerate() {
|
||||||
|
if edition.version < floor {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match by_version.get(&edition.version) {
|
||||||
|
Some(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
|
||||||
|
_ => {
|
||||||
|
by_version.insert(edition.version, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
|
||||||
|
return FoldResult::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
let lowest = editions[lowest_index];
|
||||||
|
|
||||||
|
let anchored = if floor == 0 {
|
||||||
|
lowest_version == 1 && lowest.prev.is_none()
|
||||||
|
} else if lowest_version == floor {
|
||||||
|
floor_hash == Some(&lowest.self_hash)
|
||||||
|
} else if lowest_version == floor + 1 {
|
||||||
|
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut head = Some(lowest_index);
|
||||||
|
let mut gap = !anchored;
|
||||||
|
let mut previous_version = lowest_version;
|
||||||
|
let mut previous_hash = lowest.self_hash;
|
||||||
|
|
||||||
|
for (&version, &index) in by_version.range(lowest_version + 1..) {
|
||||||
|
let edition = editions[index];
|
||||||
|
|
||||||
|
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
|
||||||
|
head = Some(index);
|
||||||
|
previous_version = version;
|
||||||
|
previous_hash = edition.self_hash;
|
||||||
|
} else {
|
||||||
|
gap = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FoldResult {
|
||||||
|
head,
|
||||||
|
gap,
|
||||||
|
anchored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highest version overall, ignoring contiguity.
|
||||||
|
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||||
|
editions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.reduce(|(best_index, best), (index, candidate)| {
|
||||||
|
let supersedes = candidate.version > best.version
|
||||||
|
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
|
||||||
|
|
||||||
|
if supersedes {
|
||||||
|
(index, candidate)
|
||||||
|
} else {
|
||||||
|
(best_index, best)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct HeadSelection {
|
||||||
|
pub head: Option<usize>,
|
||||||
|
pub gap: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The head to prefer for one entity, given what this client already committed to.
|
||||||
|
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
|
||||||
|
let Some(floor) = floor else {
|
||||||
|
return HeadSelection {
|
||||||
|
head: bootstrap_head(editions),
|
||||||
|
gap: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
|
||||||
|
|
||||||
|
if anchored.anchored {
|
||||||
|
return HeadSelection {
|
||||||
|
head: anchored.head,
|
||||||
|
gap: anchored.gap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if anchored.head.is_none() && !anchored.gap {
|
||||||
|
return HeadSelection::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fork = editions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, edition)| edition.version == floor.version)
|
||||||
|
.min_by_key(|(_, edition)| edition.tiebreak_id);
|
||||||
|
|
||||||
|
let winner = match fork {
|
||||||
|
Some((_, edition))
|
||||||
|
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
|
||||||
|
{
|
||||||
|
edition.self_hash
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return HeadSelection {
|
||||||
|
head: None,
|
||||||
|
gap: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let refolded = fold(editions, floor.version, Some(&winner));
|
||||||
|
|
||||||
|
if refolded.anchored {
|
||||||
|
HeadSelection {
|
||||||
|
head: refolded.head,
|
||||||
|
gap: refolded.gap,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HeadSelection {
|
||||||
|
head: None,
|
||||||
|
gap: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EntityHead {
|
||||||
|
pub entity: [u8; 32],
|
||||||
|
pub version: u64,
|
||||||
|
pub self_hash: [u8; 32],
|
||||||
|
pub rumor_id: EventId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ParsedEdition> for EntityHead {
|
||||||
|
fn from(edition: &ParsedEdition) -> Self {
|
||||||
|
Self {
|
||||||
|
entity: edition.entity,
|
||||||
|
version: edition.version,
|
||||||
|
self_hash: edition.self_hash,
|
||||||
|
rumor_id: edition.rumor_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every entity's committed head, keyed by coordinate.
|
||||||
|
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||||
|
|
||||||
|
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||||
|
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw.len() > 1 && raw.starts_with('0') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
|
||||||
|
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields<'a>(
|
||||||
|
rumor: &'a UnsignedEvent,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<Option<&'a [String]>, EditionError> {
|
||||||
|
let mut found: Option<&[String]> = None;
|
||||||
|
|
||||||
|
for tag in rumor.tags.iter() {
|
||||||
|
let tag_fields = tag.as_slice();
|
||||||
|
|
||||||
|
if tag_fields.first().map(String::as_str) != Some(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.is_some() {
|
||||||
|
return Err(EditionError::Duplicate(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
found = Some(tag_fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value<'a>(
|
||||||
|
rumor: &'a UnsignedEvent,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<Option<&'a str>, EditionError> {
|
||||||
|
match fields(rumor, name)? {
|
||||||
|
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
|
||||||
|
Some(_) => Err(EditionError::BadField(name)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
|
||||||
|
EditionMeta {
|
||||||
|
version,
|
||||||
|
self_hash: [hash; 32],
|
||||||
|
prev,
|
||||||
|
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
|
||||||
|
EntityHead {
|
||||||
|
entity: [0x11; 32],
|
||||||
|
version,
|
||||||
|
self_hash: [hash; 32],
|
||||||
|
rumor_id: EventId::from_byte_array([rumor; 32]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fold_picks_the_head_from_the_chain_and_the_floor() {
|
||||||
|
let chain = [
|
||||||
|
meta(1, None, 0xa1, 1),
|
||||||
|
meta(2, Some([0xa1; 32]), 0xa2, 2),
|
||||||
|
meta(3, Some([0xa2; 32]), 0xa3, 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
let folded = fold(&chain, 0, None);
|
||||||
|
assert_eq!(folded.head, Some(2));
|
||||||
|
assert!(!folded.gap && folded.anchored);
|
||||||
|
|
||||||
|
// A missing link stops the walk at the last contiguous edition.
|
||||||
|
let gapped = fold(&[chain[0], chain[2]], 0, None);
|
||||||
|
assert_eq!(gapped.head, Some(0));
|
||||||
|
assert!(gapped.gap && gapped.anchored);
|
||||||
|
|
||||||
|
// Everything below the held floor is a stale relay, not a gap.
|
||||||
|
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
|
||||||
|
assert_eq!(stale.head, None);
|
||||||
|
assert!(!stale.gap && !stale.anchored);
|
||||||
|
|
||||||
|
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
|
||||||
|
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
|
||||||
|
assert_eq!(
|
||||||
|
fold(&fork, 0, None).head,
|
||||||
|
Some(1),
|
||||||
|
"the lower rumor id wins"
|
||||||
|
);
|
||||||
|
let forked = [fork[0], fork[1], chain[1], chain[2]];
|
||||||
|
assert_eq!(fold(&forked, 0, None).head, Some(3));
|
||||||
|
|
||||||
|
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
|
||||||
|
// longer resolves is a withholding.
|
||||||
|
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
|
||||||
|
assert_eq!(
|
||||||
|
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
|
||||||
|
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
|
||||||
|
assert_eq!(refused.head, None);
|
||||||
|
assert!(refused.gap);
|
||||||
|
|
||||||
|
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
|
||||||
|
assert_eq!(bootstrap_head(&[dangling]), Some(0));
|
||||||
|
assert_eq!(fold_head(&[dangling], None).head, Some(0));
|
||||||
|
|
||||||
|
// A fork at the floor's own version converges to the lower rumor id when that is
|
||||||
|
// genuinely earlier than what we hold, and the chain above it re-anchors.
|
||||||
|
let forked = [
|
||||||
|
meta(2, Some([0xa1; 32]), 0xb2, 3),
|
||||||
|
meta(3, Some([0xb2; 32]), 0xb3, 4),
|
||||||
|
];
|
||||||
|
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
|
||||||
|
assert_eq!(converged.head, Some(1));
|
||||||
|
assert!(!converged.gap);
|
||||||
|
|
||||||
|
// A fork that is not earlier than the held head is refused.
|
||||||
|
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edition_hash_matches_the_cross_client_vector() {
|
||||||
|
let entity = [0x11u8; 32];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
|
||||||
|
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The golden vector only exercises the absent-prev encoding; pin the
|
||||||
|
// present-prev branch structurally so a swapped flag stays visible.
|
||||||
|
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
||||||
|
assert_eq!(
|
||||||
|
bytes.len(),
|
||||||
|
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
|
||||||
|
);
|
||||||
|
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
|
||||||
|
assert_eq!(
|
||||||
|
bytes[8 + EDITION_LABEL.len() + 32..][..8],
|
||||||
|
1u64.to_be_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,890 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::edition::{
|
||||||
|
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||||
|
};
|
||||||
|
use crate::stream::{
|
||||||
|
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
|
||||||
|
wrap_seal,
|
||||||
|
};
|
||||||
|
use crate::{GroupKey, decode_hex_32};
|
||||||
|
|
||||||
|
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||||
|
pub const KIND_KICK: u16 = 3309;
|
||||||
|
pub const KIND_SNAPSHOT: u16 = 3312;
|
||||||
|
|
||||||
|
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
|
||||||
|
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const TAG_INVITE: &str = "invite";
|
||||||
|
const TAG_TARGET: &str = "p";
|
||||||
|
const TAG_SNAP: &str = "snap";
|
||||||
|
const TAG_CONTENT: &str = "content";
|
||||||
|
const CONTENT_JOIN: &str = "join";
|
||||||
|
const CONTENT_LEAVE: &str = "leave";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum GuestbookError {
|
||||||
|
Stream(StreamError),
|
||||||
|
NotEncryptedSealed,
|
||||||
|
UnknownKind(u16),
|
||||||
|
MissingTag(&'static str),
|
||||||
|
DuplicateTag(&'static str),
|
||||||
|
BadTag(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for GuestbookError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
|
||||||
|
GuestbookError::NotEncryptedSealed => {
|
||||||
|
write!(f, "guestbook rumor must ride an encrypted seal")
|
||||||
|
}
|
||||||
|
GuestbookError::UnknownKind(kind) => {
|
||||||
|
write!(f, "not a guestbook rumor kind: {kind}")
|
||||||
|
}
|
||||||
|
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
|
||||||
|
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
|
||||||
|
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for GuestbookError {}
|
||||||
|
|
||||||
|
impl From<StreamError> for GuestbookError {
|
||||||
|
fn from(error: StreamError) -> Self {
|
||||||
|
GuestbookError::Stream(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum GuestbookEntry {
|
||||||
|
Join {
|
||||||
|
member: PublicKey,
|
||||||
|
at_ms: u64,
|
||||||
|
/// The `(creator, label)` an invite attributed the join to.
|
||||||
|
invited_by: Option<(String, String)>,
|
||||||
|
},
|
||||||
|
Leave {
|
||||||
|
member: PublicKey,
|
||||||
|
at_ms: u64,
|
||||||
|
},
|
||||||
|
Kick {
|
||||||
|
actor: PublicKey,
|
||||||
|
target: PublicKey,
|
||||||
|
at_ms: u64,
|
||||||
|
citation: Option<AuthorityCitation>,
|
||||||
|
},
|
||||||
|
Snapshot {
|
||||||
|
refounder: PublicKey,
|
||||||
|
members: Vec<PublicKey>,
|
||||||
|
snapshot_id: [u8; 32],
|
||||||
|
chunk: (u32, u32),
|
||||||
|
at_ms: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GuestbookRumor {
|
||||||
|
pub id: EventId,
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub entry: GuestbookEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum MemberState {
|
||||||
|
Joined {
|
||||||
|
at_ms: u64,
|
||||||
|
invited_by: Option<(String, String)>,
|
||||||
|
},
|
||||||
|
Left {
|
||||||
|
at_ms: u64,
|
||||||
|
},
|
||||||
|
Kicked {
|
||||||
|
at_ms: u64,
|
||||||
|
actor: PublicKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_join(
|
||||||
|
member: PublicKey,
|
||||||
|
invited_by: Option<(&str, &str)>,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
|
||||||
|
if let Some((creator, label)) = invited_by {
|
||||||
|
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
|
||||||
|
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_kick(
|
||||||
|
actor: PublicKey,
|
||||||
|
target: &PublicKey,
|
||||||
|
citation: Option<&AuthorityCitation>,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
|
||||||
|
|
||||||
|
if let Some(citation) = citation {
|
||||||
|
tags.push(citation_tag(citation));
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_snapshot_chunks(
|
||||||
|
refounder: PublicKey,
|
||||||
|
members: &[PublicKey],
|
||||||
|
snapshot_id: [u8; 32],
|
||||||
|
at_ms: u64,
|
||||||
|
) -> Vec<UnsignedEvent> {
|
||||||
|
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
|
||||||
|
let total = chunks.len() as u32;
|
||||||
|
|
||||||
|
chunks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, chunk)| {
|
||||||
|
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
|
||||||
|
let content = format!(
|
||||||
|
"[{}]",
|
||||||
|
hex.iter()
|
||||||
|
.map(|member| format!("\"{member}\""))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
);
|
||||||
|
let tags = vec![Tag::custom(
|
||||||
|
TAG_SNAP,
|
||||||
|
[
|
||||||
|
HEXLOWER.encode(&snapshot_id),
|
||||||
|
(index as u32 + 1).to_string(),
|
||||||
|
total.to_string(),
|
||||||
|
],
|
||||||
|
)];
|
||||||
|
|
||||||
|
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seal_rumor(
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
group: &GroupKey,
|
||||||
|
author: &Keys,
|
||||||
|
) -> Result<(Event, Keys), GuestbookError> {
|
||||||
|
let kind = rumor.kind.as_u16();
|
||||||
|
|
||||||
|
if !is_guestbook_kind(kind) {
|
||||||
|
return Err(GuestbookError::UnknownKind(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
||||||
|
|
||||||
|
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(
|
||||||
|
wrap: &Event,
|
||||||
|
group: &GroupKey,
|
||||||
|
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
|
||||||
|
let opened = open_wrap(wrap, group)?;
|
||||||
|
|
||||||
|
if opened.seal_form != SealForm::Encrypted {
|
||||||
|
return Err(GuestbookError::NotEncryptedSealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = entry_of(&opened)?;
|
||||||
|
let rumor = GuestbookRumor {
|
||||||
|
id: opened.rumor_id,
|
||||||
|
author: opened.author,
|
||||||
|
kind: opened.rumor.kind,
|
||||||
|
at_ms: opened.at_ms,
|
||||||
|
entry,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((opened, rumor))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coalesce(
|
||||||
|
rumors: &[GuestbookRumor],
|
||||||
|
now_ms: u64,
|
||||||
|
snapshot_authority: Option<&PublicKey>,
|
||||||
|
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
|
||||||
|
) -> BTreeMap<PublicKey, MemberState> {
|
||||||
|
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
|
||||||
|
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
|
||||||
|
|
||||||
|
for rumor in rumors {
|
||||||
|
if rumor.at_ms > horizon {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match &rumor.entry {
|
||||||
|
GuestbookEntry::Join {
|
||||||
|
member,
|
||||||
|
at_ms,
|
||||||
|
invited_by,
|
||||||
|
} => offer(
|
||||||
|
&mut states,
|
||||||
|
*member,
|
||||||
|
*at_ms,
|
||||||
|
rumor.id,
|
||||||
|
MemberState::Joined {
|
||||||
|
at_ms: *at_ms,
|
||||||
|
invited_by: invited_by.clone(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GuestbookEntry::Leave { member, at_ms } => offer(
|
||||||
|
&mut states,
|
||||||
|
*member,
|
||||||
|
*at_ms,
|
||||||
|
rumor.id,
|
||||||
|
MemberState::Left { at_ms: *at_ms },
|
||||||
|
),
|
||||||
|
GuestbookEntry::Kick {
|
||||||
|
actor,
|
||||||
|
target,
|
||||||
|
at_ms,
|
||||||
|
citation,
|
||||||
|
} => {
|
||||||
|
if !can_kick(actor, target, citation.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
offer(
|
||||||
|
&mut states,
|
||||||
|
*target,
|
||||||
|
*at_ms,
|
||||||
|
rumor.id,
|
||||||
|
MemberState::Kicked {
|
||||||
|
at_ms: *at_ms,
|
||||||
|
actor: *actor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GuestbookEntry::Snapshot {
|
||||||
|
refounder,
|
||||||
|
members,
|
||||||
|
at_ms,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if snapshot_authority != Some(refounder) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for member in members {
|
||||||
|
offer(
|
||||||
|
&mut states,
|
||||||
|
*member,
|
||||||
|
*at_ms,
|
||||||
|
rumor.id,
|
||||||
|
MemberState::Joined {
|
||||||
|
at_ms: *at_ms,
|
||||||
|
invited_by: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
states
|
||||||
|
.into_iter()
|
||||||
|
.map(|(member, (_, _, state))| (member, state))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete_memberlist(
|
||||||
|
coalesced: &BTreeMap<PublicKey, MemberState>,
|
||||||
|
observed: &BTreeMap<PublicKey, u64>,
|
||||||
|
granted: &BTreeSet<PublicKey>,
|
||||||
|
banned: &BTreeSet<PublicKey>,
|
||||||
|
banned_at: &BTreeMap<PublicKey, u64>,
|
||||||
|
) -> BTreeSet<PublicKey> {
|
||||||
|
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
|
||||||
|
candidates.extend(observed.keys());
|
||||||
|
candidates.extend(granted.iter());
|
||||||
|
|
||||||
|
let mut members = BTreeSet::new();
|
||||||
|
|
||||||
|
for member in candidates {
|
||||||
|
let mut inclusion = observed.get(member).copied();
|
||||||
|
|
||||||
|
if let Some(state) = coalesced.get(member) {
|
||||||
|
match state {
|
||||||
|
MemberState::Joined { at_ms, .. } => {
|
||||||
|
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
|
||||||
|
}
|
||||||
|
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inclusion.is_none() && granted.contains(member) {
|
||||||
|
inclusion = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut exclusion = match coalesced.get(member) {
|
||||||
|
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
|
||||||
|
Some(*at_ms)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if banned.contains(member) {
|
||||||
|
exclusion = Some(match banned_at.get(member) {
|
||||||
|
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
|
||||||
|
None => u64::MAX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(inclusion) = inclusion
|
||||||
|
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
|
||||||
|
{
|
||||||
|
members.insert(*member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
members
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offer(
|
||||||
|
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
|
||||||
|
member: PublicKey,
|
||||||
|
at_ms: u64,
|
||||||
|
id: EventId,
|
||||||
|
state: MemberState,
|
||||||
|
) {
|
||||||
|
let candidate = (at_ms, Reverse(id));
|
||||||
|
|
||||||
|
if let Some(existing) = states.get(&member)
|
||||||
|
&& (existing.0, existing.1) >= candidate
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
states.insert(member, (at_ms, Reverse(id), state));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_guestbook_kind(kind: u16) -> bool {
|
||||||
|
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
|
||||||
|
let rumor = &opened.rumor;
|
||||||
|
let author = opened.author;
|
||||||
|
let at_ms = opened.at_ms;
|
||||||
|
|
||||||
|
match rumor.kind.as_u16() {
|
||||||
|
KIND_JOIN_LEAVE => match rumor.content.as_str() {
|
||||||
|
CONTENT_JOIN => Ok(GuestbookEntry::Join {
|
||||||
|
member: author,
|
||||||
|
at_ms,
|
||||||
|
invited_by: invite_of(rumor),
|
||||||
|
}),
|
||||||
|
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
|
||||||
|
member: author,
|
||||||
|
at_ms,
|
||||||
|
}),
|
||||||
|
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
|
||||||
|
},
|
||||||
|
KIND_KICK => Ok(GuestbookEntry::Kick {
|
||||||
|
actor: author,
|
||||||
|
target: tagged_pubkey(rumor, TAG_TARGET)?,
|
||||||
|
at_ms,
|
||||||
|
citation: optional_citation(rumor)?,
|
||||||
|
}),
|
||||||
|
KIND_SNAPSHOT => {
|
||||||
|
let (snapshot_id, chunk) = snapshot_of(rumor)?;
|
||||||
|
let members = members_of(&rumor.content)?;
|
||||||
|
|
||||||
|
Ok(GuestbookEntry::Snapshot {
|
||||||
|
refounder: author,
|
||||||
|
members,
|
||||||
|
snapshot_id,
|
||||||
|
chunk,
|
||||||
|
at_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => Err(GuestbookError::UnknownKind(other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
|
||||||
|
rumor.tags.iter().find_map(|candidate| {
|
||||||
|
let fields = candidate.as_slice();
|
||||||
|
|
||||||
|
(fields.len() >= 3 && fields[0] == TAG_INVITE)
|
||||||
|
.then(|| (fields[1].clone(), fields[2].clone()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
|
||||||
|
let entries: Vec<String> =
|
||||||
|
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
|
||||||
|
|
||||||
|
if entries.len() > MAX_SNAPSHOT_CHUNK {
|
||||||
|
return Err(GuestbookError::BadTag(TAG_SNAP));
|
||||||
|
}
|
||||||
|
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| pubkey(entry, TAG_CONTENT))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
|
||||||
|
let fields = required(rumor, TAG_SNAP)?;
|
||||||
|
|
||||||
|
if fields.len() != 4 {
|
||||||
|
return Err(GuestbookError::BadTag(TAG_SNAP));
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
|
||||||
|
let index = decimal(&fields[2])?;
|
||||||
|
let total = decimal(&fields[3])?;
|
||||||
|
|
||||||
|
if index == 0 || index > total {
|
||||||
|
return Err(GuestbookError::BadTag(TAG_SNAP));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((snapshot_id, (index, total)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
|
||||||
|
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
citation_from(fields)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or(GuestbookError::BadTag(TAG_CITATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
|
||||||
|
canonical_decimal(raw)
|
||||||
|
.and_then(|value| u32::try_from(value).ok())
|
||||||
|
.ok_or(GuestbookError::BadTag(TAG_SNAP))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required<'a>(
|
||||||
|
rumor: &'a UnsignedEvent,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<&'a [String], GuestbookError> {
|
||||||
|
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||||
|
pubkey(value(required(rumor, name)?, name)?, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag<'a>(
|
||||||
|
rumor: &'a UnsignedEvent,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<Option<&'a [String]>, GuestbookError> {
|
||||||
|
let mut found: Option<&[String]> = None;
|
||||||
|
|
||||||
|
for candidate in rumor.tags.iter() {
|
||||||
|
let fields = candidate.as_slice();
|
||||||
|
|
||||||
|
if fields.first().map(String::as_str) != Some(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.is_some() {
|
||||||
|
return Err(GuestbookError::DuplicateTag(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
found = Some(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
|
||||||
|
fields
|
||||||
|
.get(1)
|
||||||
|
.map(String::as_str)
|
||||||
|
.ok_or(GuestbookError::BadTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||||
|
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
|
||||||
|
|
||||||
|
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::derive::guestbook_group_key;
|
||||||
|
use crate::stream::build_rumor_secs;
|
||||||
|
use crate::{CommunityId, Epoch};
|
||||||
|
|
||||||
|
const ROOT: [u8; 32] = [0x5au8; 32];
|
||||||
|
const AT: u64 = 1_700_000_000_000;
|
||||||
|
|
||||||
|
fn community() -> CommunityId {
|
||||||
|
CommunityId::from_bytes([0x11u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group() -> GroupKey {
|
||||||
|
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn citation() -> AuthorityCitation {
|
||||||
|
AuthorityCitation {
|
||||||
|
entity: [0x33u8; 32],
|
||||||
|
version: 1,
|
||||||
|
hash: [0x44u8; 32],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
|
||||||
|
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
|
||||||
|
|
||||||
|
open(&wrap, &group()).expect("opens").1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
|
||||||
|
let alice = Keys::generate();
|
||||||
|
let bob = Keys::generate();
|
||||||
|
let carol = Keys::generate();
|
||||||
|
let dave = Keys::generate();
|
||||||
|
let frank = Keys::generate();
|
||||||
|
let grace = Keys::generate();
|
||||||
|
let owner = Keys::generate();
|
||||||
|
|
||||||
|
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
|
||||||
|
|
||||||
|
let mut rumors = vec![
|
||||||
|
publish(
|
||||||
|
&build_join(
|
||||||
|
alice.public_key(),
|
||||||
|
Some((&"ab".repeat(32), "Reddit")),
|
||||||
|
AT + 1_000,
|
||||||
|
),
|
||||||
|
&alice,
|
||||||
|
),
|
||||||
|
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
|
||||||
|
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
|
||||||
|
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
|
||||||
|
publish(
|
||||||
|
&build_kick(
|
||||||
|
carol.public_key(),
|
||||||
|
&dave.public_key(),
|
||||||
|
Some(&citation()),
|
||||||
|
AT + 5_000,
|
||||||
|
),
|
||||||
|
&carol,
|
||||||
|
),
|
||||||
|
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
|
||||||
|
];
|
||||||
|
|
||||||
|
let snapshot_id = "77".repeat(32);
|
||||||
|
let chunks =
|
||||||
|
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
|
||||||
|
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
|
||||||
|
for (index, chunk) in chunks.iter().enumerate() {
|
||||||
|
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
|
||||||
|
== [
|
||||||
|
TAG_SNAP,
|
||||||
|
snapshot_id.as_str(),
|
||||||
|
&(index + 1).to_string(),
|
||||||
|
"2"
|
||||||
|
]));
|
||||||
|
rumors.push(publish(chunk, &carol));
|
||||||
|
}
|
||||||
|
|
||||||
|
let can_kick =
|
||||||
|
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
|
||||||
|
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
|
||||||
|
};
|
||||||
|
|
||||||
|
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
states.get(&alice.public_key()),
|
||||||
|
Some(&MemberState::Joined {
|
||||||
|
at_ms: AT + 1_000,
|
||||||
|
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
states.get(&bob.public_key()),
|
||||||
|
Some(&MemberState::Left { at_ms: AT + 3_000 })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
states.get(&dave.public_key()),
|
||||||
|
Some(&MemberState::Kicked {
|
||||||
|
at_ms: AT + 5_000,
|
||||||
|
actor: carol.public_key(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
survivors
|
||||||
|
.iter()
|
||||||
|
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
|
||||||
|
"every chunk seeds its own members"
|
||||||
|
);
|
||||||
|
|
||||||
|
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
|
||||||
|
assert_eq!(
|
||||||
|
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
|
||||||
|
states,
|
||||||
|
"arrival order cannot change the fold"
|
||||||
|
);
|
||||||
|
|
||||||
|
let observed = BTreeMap::from([
|
||||||
|
(bob.public_key(), AT + 9_000),
|
||||||
|
(carol.public_key(), AT + 5_000),
|
||||||
|
]);
|
||||||
|
let granted = BTreeSet::from([grace.public_key()]);
|
||||||
|
let banned = BTreeSet::from([frank.public_key()]);
|
||||||
|
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
|
||||||
|
|
||||||
|
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
|
||||||
|
|
||||||
|
let mut expected = BTreeSet::from([
|
||||||
|
alice.public_key(),
|
||||||
|
bob.public_key(),
|
||||||
|
carol.public_key(),
|
||||||
|
grace.public_key(),
|
||||||
|
]);
|
||||||
|
expected.extend(survivors.iter().copied());
|
||||||
|
|
||||||
|
assert_eq!(members, expected);
|
||||||
|
assert!(
|
||||||
|
!members.contains(&dave.public_key()),
|
||||||
|
"a kicked member is out"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!members.contains(&frank.public_key()),
|
||||||
|
"a ban wins over a later join"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kick_or_snapshot_without_authority_is_dropped() {
|
||||||
|
let moderator = Keys::generate();
|
||||||
|
let outsider = Keys::generate();
|
||||||
|
let owner = Keys::generate();
|
||||||
|
let kicked = Keys::generate();
|
||||||
|
let uncited = Keys::generate();
|
||||||
|
let unranked = Keys::generate();
|
||||||
|
let refounder = Keys::generate();
|
||||||
|
let impostor = Keys::generate();
|
||||||
|
let seeded = Keys::generate();
|
||||||
|
let smuggled = Keys::generate();
|
||||||
|
|
||||||
|
let can_kick = |actor: &PublicKey,
|
||||||
|
target: &PublicKey,
|
||||||
|
citation: Option<&AuthorityCitation>| {
|
||||||
|
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
|
||||||
|
};
|
||||||
|
|
||||||
|
let rumors = vec![
|
||||||
|
publish(
|
||||||
|
&build_kick(
|
||||||
|
moderator.public_key(),
|
||||||
|
&kicked.public_key(),
|
||||||
|
Some(&citation()),
|
||||||
|
AT,
|
||||||
|
),
|
||||||
|
&moderator,
|
||||||
|
),
|
||||||
|
publish(
|
||||||
|
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
|
||||||
|
&moderator,
|
||||||
|
),
|
||||||
|
publish(
|
||||||
|
&build_kick(
|
||||||
|
outsider.public_key(),
|
||||||
|
&unranked.public_key(),
|
||||||
|
Some(&citation()),
|
||||||
|
AT,
|
||||||
|
),
|
||||||
|
&outsider,
|
||||||
|
),
|
||||||
|
publish(
|
||||||
|
&build_kick(
|
||||||
|
moderator.public_key(),
|
||||||
|
&owner.public_key(),
|
||||||
|
Some(&citation()),
|
||||||
|
AT,
|
||||||
|
),
|
||||||
|
&moderator,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
states.get(&kicked.public_key()),
|
||||||
|
Some(&MemberState::Kicked {
|
||||||
|
at_ms: AT,
|
||||||
|
actor: moderator.public_key(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!states.contains_key(&uncited.public_key()),
|
||||||
|
"a kick cites the Grant it acts under"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!states.contains_key(&unranked.public_key()),
|
||||||
|
"a kick needs KICK"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!states.contains_key(&owner.public_key()),
|
||||||
|
"nobody kicks the owner"
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_refounder = build_snapshot_chunks(
|
||||||
|
refounder.public_key(),
|
||||||
|
&[seeded.public_key()],
|
||||||
|
[0x77u8; 32],
|
||||||
|
AT,
|
||||||
|
)
|
||||||
|
.remove(0);
|
||||||
|
let by_impostor = build_snapshot_chunks(
|
||||||
|
impostor.public_key(),
|
||||||
|
&[smuggled.public_key()],
|
||||||
|
[0x88u8; 32],
|
||||||
|
AT,
|
||||||
|
)
|
||||||
|
.remove(0);
|
||||||
|
|
||||||
|
for authority in [None, Some(refounder.public_key())] {
|
||||||
|
let states = coalesce(
|
||||||
|
&[
|
||||||
|
publish(&by_refounder, &refounder),
|
||||||
|
publish(&by_impostor, &impostor),
|
||||||
|
],
|
||||||
|
AT + 1_000,
|
||||||
|
authority.as_ref(),
|
||||||
|
|_, _, _| true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
states.contains_key(&seeded.public_key()),
|
||||||
|
authority.is_some(),
|
||||||
|
"only the epoch's refounder seeds, and there is no owner fallback"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!states.contains_key(&smuggled.public_key()),
|
||||||
|
"a foreign snapshot never seeds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
|
||||||
|
let member = Keys::generate();
|
||||||
|
let moderator = Keys::generate();
|
||||||
|
let target = Keys::generate();
|
||||||
|
|
||||||
|
let future = publish(
|
||||||
|
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
|
||||||
|
&member,
|
||||||
|
);
|
||||||
|
let horizon = publish(
|
||||||
|
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
|
||||||
|
&member,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
|
||||||
|
"an entry more than an hour ahead is dropped"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
|
||||||
|
1,
|
||||||
|
"the horizon itself is skew, not forgery"
|
||||||
|
);
|
||||||
|
|
||||||
|
let bad_ms = build_rumor_secs(
|
||||||
|
KIND_JOIN_LEAVE,
|
||||||
|
member.public_key(),
|
||||||
|
CONTENT_JOIN,
|
||||||
|
vec![Tag::custom("ms", ["1000"])],
|
||||||
|
AT / 1000,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
|
||||||
|
&group()
|
||||||
|
),
|
||||||
|
Err(GuestbookError::Stream(StreamError::BadMs))
|
||||||
|
));
|
||||||
|
|
||||||
|
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
|
||||||
|
&group()
|
||||||
|
),
|
||||||
|
Err(GuestbookError::BadTag(TAG_CONTENT))
|
||||||
|
));
|
||||||
|
|
||||||
|
let ambiguous = build_rumor_ms(
|
||||||
|
KIND_KICK,
|
||||||
|
moderator.public_key(),
|
||||||
|
"",
|
||||||
|
vec![
|
||||||
|
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
|
||||||
|
citation_tag(&citation()),
|
||||||
|
citation_tag(&citation()),
|
||||||
|
],
|
||||||
|
AT,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&seal_rumor(&ambiguous, &group(), &moderator)
|
||||||
|
.expect("seals")
|
||||||
|
.0,
|
||||||
|
&group()
|
||||||
|
),
|
||||||
|
Err(GuestbookError::DuplicateTag(TAG_CITATION))
|
||||||
|
));
|
||||||
|
|
||||||
|
for fields in [
|
||||||
|
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
|
||||||
|
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
|
||||||
|
vec![snapshot_id(), "1".to_owned()],
|
||||||
|
] {
|
||||||
|
let rumor = build_rumor_ms(
|
||||||
|
KIND_SNAPSHOT,
|
||||||
|
moderator.public_key(),
|
||||||
|
"[]",
|
||||||
|
vec![Tag::custom(TAG_SNAP, fields)],
|
||||||
|
AT,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
open(
|
||||||
|
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
|
||||||
|
&group()
|
||||||
|
),
|
||||||
|
Err(GuestbookError::BadTag(TAG_SNAP))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_id() -> String {
|
||||||
|
"ab".repeat(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::btree_map::Entry;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use data_encoding::BASE64URL_NOPAD;
|
||||||
|
use nostr::nips::nip01::Coordinate;
|
||||||
|
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
|
||||||
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
|
use nostr::nips::nip44::{self, Version};
|
||||||
|
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::control::{ImageRef, MAX_RELAYS};
|
||||||
|
use crate::derive::{TOKEN_LEN, verify_community_id};
|
||||||
|
use crate::edition::{TAG_SUBKIND, vsk};
|
||||||
|
use crate::list::{canonical, union};
|
||||||
|
use crate::stream::{self, NIP44_MAX_PLAINTEXT, StreamError};
|
||||||
|
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||||
|
|
||||||
|
pub const KIND_BUNDLE: u16 = 33301;
|
||||||
|
pub const KIND_INVITE_LIST: u16 = 13303;
|
||||||
|
pub const KIND_DIRECT_INVITE: u16 = 3313;
|
||||||
|
pub const FRAGMENT_VERSION: u8 = 4;
|
||||||
|
pub const MAX_BUNDLE_CHANNELS: usize = 256;
|
||||||
|
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
|
||||||
|
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
|
||||||
|
pub const MAX_INVITE_ENTRIES: usize = 64;
|
||||||
|
|
||||||
|
const FLAG_STOCK_SET: u8 = 0x01;
|
||||||
|
const INVITE_PATH: &str = "/invite/";
|
||||||
|
const TAG_IDENTIFIER: &str = "d";
|
||||||
|
const TAG_EXPIRATION: &str = "expiration";
|
||||||
|
|
||||||
|
const RELAY_DICT: [&str; 4] = [
|
||||||
|
"wss://jskitty.com/nostr",
|
||||||
|
"wss://asia.vectorapp.io/nostr",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://relay.dreamith.to",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum InviteError {
|
||||||
|
Stream(StreamError),
|
||||||
|
Json(String),
|
||||||
|
BadHex(&'static str),
|
||||||
|
TooManyChannels(usize),
|
||||||
|
TooManyInvites(usize),
|
||||||
|
Oversize(usize),
|
||||||
|
Kind(u16),
|
||||||
|
EpochTooLarge(u64),
|
||||||
|
OwnerMismatch,
|
||||||
|
BadFragment(&'static str),
|
||||||
|
BadVersion(u8),
|
||||||
|
BadLink(&'static str),
|
||||||
|
BadEvent(&'static str),
|
||||||
|
Crypto(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InviteError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
InviteError::Stream(error) => write!(f, "stream: {error}"),
|
||||||
|
InviteError::Json(error) => write!(f, "json: {error}"),
|
||||||
|
InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"),
|
||||||
|
InviteError::TooManyChannels(count) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InviteError::TooManyInvites(count) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InviteError::Oversize(len) => {
|
||||||
|
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
|
||||||
|
}
|
||||||
|
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
|
||||||
|
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
|
||||||
|
InviteError::OwnerMismatch => {
|
||||||
|
write!(f, "bundle owner does not reproduce its community_id")
|
||||||
|
}
|
||||||
|
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
|
||||||
|
InviteError::BadVersion(version) => {
|
||||||
|
write!(f, "unsupported invite fragment version {version}")
|
||||||
|
}
|
||||||
|
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
|
||||||
|
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
|
||||||
|
InviteError::Crypto(error) => write!(f, "crypto: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for InviteError {}
|
||||||
|
|
||||||
|
impl From<StreamError> for InviteError {
|
||||||
|
fn from(error: StreamError) -> Self {
|
||||||
|
InviteError::Stream(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelGrant {
|
||||||
|
pub id: ChannelId,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub key: Option<String>,
|
||||||
|
pub epoch: Epoch,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityInvite {
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
pub owner: PublicKey,
|
||||||
|
pub owner_salt: String,
|
||||||
|
pub community_root: String,
|
||||||
|
pub root_epoch: Epoch,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub control_pk: Option<PublicKey>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub channels: Vec<ChannelGrant>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub relays: Vec<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<ImageRef>,
|
||||||
|
/// Unix **ms**: past it the preview still renders, joining refuses.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creator_npub: Option<PublicKey>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityInvite {
|
||||||
|
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
|
||||||
|
let mut invite: Self =
|
||||||
|
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
|
||||||
|
|
||||||
|
if invite.channels.len() > MAX_BUNDLE_CHANNELS {
|
||||||
|
return Err(InviteError::TooManyChannels(invite.channels.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
invite.relays.truncate(MAX_RELAYS);
|
||||||
|
invite.validate()?;
|
||||||
|
|
||||||
|
Ok(invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), InviteError> {
|
||||||
|
if self.channels.len() > MAX_BUNDLE_CHANNELS {
|
||||||
|
return Err(InviteError::TooManyChannels(self.channels.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) {
|
||||||
|
if epoch.0 > MAX_BUNDLE_EPOCH {
|
||||||
|
return Err(InviteError::EpochTooLarge(epoch.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner_salt = hex32(&self.owner_salt, "owner_salt")?;
|
||||||
|
hex32(&self.community_root, "community_root")?;
|
||||||
|
|
||||||
|
for channel in &self.channels {
|
||||||
|
if let Some(key) = &channel.key {
|
||||||
|
hex32(key, "channel key")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) {
|
||||||
|
return Err(InviteError::OwnerMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expired(&self, now_ms: u64) -> bool {
|
||||||
|
self.expires_at.is_some_and(|expires| now_ms > expires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum BundleState {
|
||||||
|
Live(Box<CommunityInvite>),
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_bundle_event(
|
||||||
|
link_signer: &Keys,
|
||||||
|
invite: &CommunityInvite,
|
||||||
|
bundle_key: &[u8; 32],
|
||||||
|
) -> Result<Event, InviteError> {
|
||||||
|
invite.validate()?;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(invite).map_err(json_error)?;
|
||||||
|
let content = seal_bundle(bundle_key, &json)?;
|
||||||
|
|
||||||
|
EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
|
||||||
|
.tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)])
|
||||||
|
.finalize(link_signer)
|
||||||
|
.map_err(crypto_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
|
||||||
|
EventBuilder::new(Kind::Custom(KIND_BUNDLE), "")
|
||||||
|
.tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)])
|
||||||
|
.finalize(link_signer)
|
||||||
|
.map_err(crypto_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bundle_event(
|
||||||
|
event: &Event,
|
||||||
|
expected_signer: &PublicKey,
|
||||||
|
bundle_key: &[u8; 32],
|
||||||
|
) -> Result<BundleState, InviteError> {
|
||||||
|
if event.kind.as_u16() != KIND_BUNDLE {
|
||||||
|
return Err(InviteError::BadEvent("wrong kind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.pubkey != *expected_signer {
|
||||||
|
return Err(InviteError::BadEvent("author is not the link signer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) {
|
||||||
|
return Err(InviteError::BadEvent(
|
||||||
|
"bundle is not at the link's coordinate",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
event
|
||||||
|
.verify()
|
||||||
|
.map_err(|_| InviteError::BadEvent("signature invalid"))?;
|
||||||
|
|
||||||
|
match first_tag(event, TAG_SUBKIND).as_deref() {
|
||||||
|
Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked),
|
||||||
|
Some(vsk::INVITE_LIVE) => {}
|
||||||
|
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = open_bundle(bundle_key, &event.content)?;
|
||||||
|
|
||||||
|
Ok(BundleState::Live(Box::new(
|
||||||
|
CommunityInvite::from_bundle_json(&json)?,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stock_relays() -> Vec<String> {
|
||||||
|
RELAY_DICT.iter().map(|relay| relay.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
|
||||||
|
let stock = relays == RELAY_DICT;
|
||||||
|
|
||||||
|
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
|
||||||
|
bytes.push(FRAGMENT_VERSION);
|
||||||
|
|
||||||
|
if stock {
|
||||||
|
bytes.push(FLAG_STOCK_SET);
|
||||||
|
} else {
|
||||||
|
bytes.push(0x00);
|
||||||
|
|
||||||
|
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
|
||||||
|
bytes.push(bounded.len() as u8);
|
||||||
|
|
||||||
|
for relay in bounded {
|
||||||
|
match dict_id(relay) {
|
||||||
|
Some(id) => bytes.push(id),
|
||||||
|
None => {
|
||||||
|
let (lead, literal) = match relay.strip_prefix("wss://") {
|
||||||
|
Some(host) => (0x00, host),
|
||||||
|
None => (0xff, relay.as_str()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if literal.len() > u8::MAX as usize {
|
||||||
|
return Err(InviteError::BadFragment("relay too long"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.extend_from_slice(&[lead, literal.len() as u8]);
|
||||||
|
bytes.extend_from_slice(literal.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.extend_from_slice(token);
|
||||||
|
|
||||||
|
Ok(BASE64URL_NOPAD.encode(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
|
||||||
|
let bytes = BASE64URL_NOPAD
|
||||||
|
.decode(fragment.trim().as_bytes())
|
||||||
|
.map_err(|_| InviteError::BadFragment("not base64url"))?;
|
||||||
|
|
||||||
|
let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
|
||||||
|
if version != FRAGMENT_VERSION {
|
||||||
|
return Err(InviteError::BadVersion(version));
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
|
||||||
|
let mut offset = 2;
|
||||||
|
let mut relays = Vec::new();
|
||||||
|
|
||||||
|
if flags & FLAG_STOCK_SET != 0 {
|
||||||
|
relays = stock_relays();
|
||||||
|
} else {
|
||||||
|
let count = *bytes
|
||||||
|
.get(offset)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))? as usize;
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
if count > MAX_BOOTSTRAP_RELAYS {
|
||||||
|
return Err(InviteError::BadFragment("too many bootstrap relays"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..count {
|
||||||
|
let lead = *bytes
|
||||||
|
.get(offset)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
if (1..=254).contains(&lead) {
|
||||||
|
if let Some(url) = dict_url(lead) {
|
||||||
|
relays.push(url.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = *bytes
|
||||||
|
.get(offset)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))? as usize;
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
let end = offset
|
||||||
|
.checked_add(len)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
|
||||||
|
let raw = bytes
|
||||||
|
.get(offset..end)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
|
||||||
|
let text = std::str::from_utf8(raw)
|
||||||
|
.map_err(|_| InviteError::BadFragment("relay is not utf8"))?;
|
||||||
|
|
||||||
|
relays.push(match lead {
|
||||||
|
0x00 => format!("wss://{text}"),
|
||||||
|
0xff => text.to_string(),
|
||||||
|
_ => return Err(InviteError::BadFragment("unknown relay lead byte")),
|
||||||
|
});
|
||||||
|
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = offset
|
||||||
|
.checked_add(TOKEN_LEN)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
|
||||||
|
let raw = bytes
|
||||||
|
.get(offset..end)
|
||||||
|
.ok_or(InviteError::BadFragment("truncated"))?;
|
||||||
|
|
||||||
|
if end != bytes.len() {
|
||||||
|
return Err(InviteError::BadFragment("trailing bytes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut token = [0u8; TOKEN_LEN];
|
||||||
|
token.copy_from_slice(raw);
|
||||||
|
|
||||||
|
Ok((token, relays))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
|
||||||
|
let coordinate = Coordinate {
|
||||||
|
kind: Kind::Custom(KIND_BUNDLE),
|
||||||
|
public_key: *link_signer,
|
||||||
|
identifier: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Nip19::Coordinate(Nip19Coordinate {
|
||||||
|
coordinate,
|
||||||
|
relays: Vec::new(),
|
||||||
|
})
|
||||||
|
.to_bech32()
|
||||||
|
.map_err(|_| InviteError::BadLink("invalid naddr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_invite_url(
|
||||||
|
base: &str,
|
||||||
|
link_signer: &PublicKey,
|
||||||
|
token: &[u8; TOKEN_LEN],
|
||||||
|
relays: &[String],
|
||||||
|
) -> Result<String, InviteError> {
|
||||||
|
let naddr = bundle_naddr(link_signer)?;
|
||||||
|
let fragment = encode_fragment(token, relays)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"{}{INVITE_PATH}{naddr}#{fragment}",
|
||||||
|
base.trim_end_matches('/')
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParsedInviteLink {
|
||||||
|
/// The bundle coordinate's author.
|
||||||
|
pub link_signer: PublicKey,
|
||||||
|
pub token: [u8; TOKEN_LEN],
|
||||||
|
pub bootstrap_relays: Vec<String>,
|
||||||
|
/// The bare naddr as it appeared in the link, for the fetch.
|
||||||
|
pub naddr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
|
||||||
|
let (locator, fragment) = input
|
||||||
|
.trim()
|
||||||
|
.split_once('#')
|
||||||
|
.ok_or(InviteError::BadLink("no fragment"))?;
|
||||||
|
|
||||||
|
if fragment.is_empty() {
|
||||||
|
return Err(InviteError::BadLink("empty fragment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let naddr = match locator.find(INVITE_PATH) {
|
||||||
|
Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'),
|
||||||
|
None => locator.trim_start_matches("nostr:"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let link_signer = signer_from_naddr(naddr)?;
|
||||||
|
let (token, bootstrap_relays) = decode_fragment(fragment)?;
|
||||||
|
|
||||||
|
Ok(ParsedInviteLink {
|
||||||
|
link_signer,
|
||||||
|
token,
|
||||||
|
bootstrap_relays,
|
||||||
|
naddr: naddr.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_direct_invite(
|
||||||
|
inviter: &Keys,
|
||||||
|
recipient: &PublicKey,
|
||||||
|
invite: &CommunityInvite,
|
||||||
|
) -> Result<Event, InviteError> {
|
||||||
|
invite.validate()?;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(invite).map_err(json_error)?;
|
||||||
|
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
|
||||||
|
.finalize_unsigned(inviter.public_key());
|
||||||
|
|
||||||
|
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
|
||||||
|
|
||||||
|
if let Some(expires_at) = invite.expires_at {
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TAG_EXPIRATION,
|
||||||
|
[(expires_at / 1000).to_string()],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
GiftWrapBuilder::new(*recipient, rumor)
|
||||||
|
.extra_tags(tags)
|
||||||
|
.finalize(inviter)
|
||||||
|
.map_err(crypto_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwrap_direct_invite(
|
||||||
|
wrap: &Event,
|
||||||
|
recipient: &Keys,
|
||||||
|
) -> Result<(PublicKey, CommunityInvite), InviteError> {
|
||||||
|
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
|
||||||
|
|
||||||
|
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
|
||||||
|
return Err(InviteError::BadEvent("rumor is not a direct invite"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
unwrapped.sender,
|
||||||
|
CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct InviteEntry {
|
||||||
|
/// The link's unlock secret, and its merge key.
|
||||||
|
pub token: String,
|
||||||
|
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
|
||||||
|
pub signer_sk: String,
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct InviteTombstone {
|
||||||
|
pub token: String,
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A creator's own link bookkeeping.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct InviteList {
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub entries: Vec<InviteEntry>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tombstones: Vec<InviteTombstone>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InviteList {
|
||||||
|
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
|
||||||
|
pub fn is_live(&self, token: &str) -> bool {
|
||||||
|
self.entries.iter().any(|entry| entry.token == token)
|
||||||
|
&& !self
|
||||||
|
.tombstones
|
||||||
|
.iter()
|
||||||
|
.any(|tombstone| tombstone.token == token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fits(&self) -> Result<(), InviteError> {
|
||||||
|
if self.entries.len() > MAX_INVITE_ENTRIES {
|
||||||
|
return Err(InviteError::TooManyInvites(self.entries.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string(self).map_err(json_error)?;
|
||||||
|
|
||||||
|
if json.len() > NIP44_MAX_PLAINTEXT {
|
||||||
|
return Err(InviteError::Oversize(json.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
|
||||||
|
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
|
||||||
|
|
||||||
|
for entry in held.entries.into_iter().chain(incoming.entries) {
|
||||||
|
match entries.entry(entry.token.clone()) {
|
||||||
|
Entry::Vacant(slot) => {
|
||||||
|
slot.insert(entry);
|
||||||
|
}
|
||||||
|
Entry::Occupied(mut slot) => {
|
||||||
|
let merged = merge_entry(slot.get(), &entry);
|
||||||
|
*slot.get_mut() = merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
|
||||||
|
|
||||||
|
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
|
||||||
|
match tombstones.entry(tombstone.token.clone()) {
|
||||||
|
Entry::Vacant(slot) => {
|
||||||
|
slot.insert(tombstone);
|
||||||
|
}
|
||||||
|
Entry::Occupied(mut slot) => {
|
||||||
|
if canonical(&tombstone) < canonical(slot.get()) {
|
||||||
|
*slot.get_mut() = tombstone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut extra = held.extra;
|
||||||
|
union(&mut extra, incoming.extra);
|
||||||
|
|
||||||
|
InviteList {
|
||||||
|
entries: entries.into_values().collect(),
|
||||||
|
tombstones: tombstones.into_values().collect(),
|
||||||
|
extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
|
||||||
|
list.fits()?;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||||
|
let content = nip44::encrypt(
|
||||||
|
keys.secret_key(),
|
||||||
|
&keys.public_key(),
|
||||||
|
json.as_bytes(),
|
||||||
|
Version::V2,
|
||||||
|
)
|
||||||
|
.map_err(crypto_error)?;
|
||||||
|
|
||||||
|
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
||||||
|
.finalize(keys)
|
||||||
|
.map_err(crypto_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
|
||||||
|
if event.kind.as_u16() != KIND_INVITE_LIST {
|
||||||
|
return Err(InviteError::Kind(event.kind.as_u16()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
|
||||||
|
.map_err(crypto_error)?;
|
||||||
|
|
||||||
|
serde_json::from_str(&json).map_err(json_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An entry is immutable once minted, so two copies should agree.
|
||||||
|
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
|
||||||
|
let (winner, loser) = if canonical(incoming) < canonical(held) {
|
||||||
|
(incoming, held)
|
||||||
|
} else {
|
||||||
|
(held, incoming)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut merged = winner.clone();
|
||||||
|
union(&mut merged.extra, loser.extra.clone());
|
||||||
|
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
|
||||||
|
Ok(stream::seal_bytes(
|
||||||
|
&ConversationKey::new(*bundle_key),
|
||||||
|
json.as_bytes(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
|
||||||
|
let plaintext = stream::open_bytes(&ConversationKey::new(*bundle_key), content)?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
|
||||||
|
match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) {
|
||||||
|
Ok(Nip19::Coordinate(coordinate))
|
||||||
|
if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE
|
||||||
|
&& coordinate.coordinate.identifier.is_empty() =>
|
||||||
|
{
|
||||||
|
Ok(coordinate.coordinate.public_key)
|
||||||
|
}
|
||||||
|
_ => Err(InviteError::BadLink(
|
||||||
|
"naddr is not an invite-bundle coordinate",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
|
||||||
|
decode_hex_32(value).map_err(|_| InviteError::BadHex(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dict_id(relay: &str) -> Option<u8> {
|
||||||
|
RELAY_DICT
|
||||||
|
.iter()
|
||||||
|
.position(|known| *known == relay)
|
||||||
|
.map(|index| index as u8 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dict_url(id: u8) -> Option<&'static str> {
|
||||||
|
RELAY_DICT.get(id.checked_sub(1)? as usize).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_identifier() -> Tag {
|
||||||
|
Tag::identifier("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subkind_tag(value: &str) -> Tag {
|
||||||
|
Tag::custom(TAG_SUBKIND, [value])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_tag(event: &Event, name: &str) -> Option<String> {
|
||||||
|
event.tags.iter().find_map(|tag| {
|
||||||
|
let fields = tag.as_slice();
|
||||||
|
|
||||||
|
(fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_error(error: serde_json::Error) -> InviteError {
|
||||||
|
InviteError::Json(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crypto_error(error: impl fmt::Display) -> InviteError {
|
||||||
|
InviteError::Crypto(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::derive::{community_id_of, invite_bundle_key};
|
||||||
|
|
||||||
|
const SALT: [u8; 32] = [0x33u8; 32];
|
||||||
|
|
||||||
|
fn bundle() -> CommunityInvite {
|
||||||
|
let owner = Keys::generate();
|
||||||
|
|
||||||
|
CommunityInvite {
|
||||||
|
community_id: community_id_of(&owner.public_key().to_bytes(), &SALT),
|
||||||
|
owner: owner.public_key(),
|
||||||
|
owner_salt: HEXLOWER.encode(&SALT),
|
||||||
|
community_root: "44".repeat(32),
|
||||||
|
root_epoch: Epoch(0),
|
||||||
|
control_pk: None,
|
||||||
|
channels: vec![ChannelGrant {
|
||||||
|
id: ChannelId::from_bytes([0x9cu8; 32]),
|
||||||
|
key: Some("55".repeat(32)),
|
||||||
|
epoch: Epoch(1),
|
||||||
|
name: "lounge".to_owned(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
}],
|
||||||
|
relays: vec!["wss://relay.example".to_owned()],
|
||||||
|
name: "Test community".to_owned(),
|
||||||
|
icon: None,
|
||||||
|
expires_at: None,
|
||||||
|
creator_npub: None,
|
||||||
|
label: None,
|
||||||
|
extra: Extra::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token16() -> [u8; TOKEN_LEN] {
|
||||||
|
std::array::from_fn(|i| i as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fragment_goldens_pin_the_wire_layout() {
|
||||||
|
let token = token16();
|
||||||
|
|
||||||
|
// [04 version][01 stock flag][token 00..0f]
|
||||||
|
let stock = encode_fragment(&token, &stock_relays()).expect("encodes");
|
||||||
|
assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P");
|
||||||
|
assert_eq!(
|
||||||
|
decode_fragment(&stock).expect("decodes"),
|
||||||
|
(token, stock_relays())
|
||||||
|
);
|
||||||
|
|
||||||
|
// [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f]
|
||||||
|
let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()];
|
||||||
|
let encoded = encode_fragment(&token, &mixed).expect("encodes");
|
||||||
|
assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
|
||||||
|
assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed));
|
||||||
|
|
||||||
|
// [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f]
|
||||||
|
let verbatim = vec!["ws://h".to_owned()];
|
||||||
|
let encoded = encode_fragment(&token, &verbatim).expect("encodes");
|
||||||
|
assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
|
||||||
|
assert_eq!(
|
||||||
|
decode_fragment(&encoded).expect("decodes"),
|
||||||
|
(token, verbatim)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fragment_is_strict_about_framing_and_counts() {
|
||||||
|
let token = token16();
|
||||||
|
|
||||||
|
for version in [3u8, 5] {
|
||||||
|
let mut bytes = vec![version, FLAG_STOCK_SET];
|
||||||
|
bytes.extend_from_slice(&token);
|
||||||
|
let encoded = BASE64URL_NOPAD.encode(&bytes);
|
||||||
|
assert!(
|
||||||
|
matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version),
|
||||||
|
"a legacy and a future version are both refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
|
||||||
|
trailing.extend_from_slice(&token);
|
||||||
|
trailing.push(0xff);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fragment(&BASE64URL_NOPAD.encode(&trailing)),
|
||||||
|
Err(InviteError::BadFragment(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4];
|
||||||
|
over.extend_from_slice(&token);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fragment(&BASE64URL_NOPAD.encode(&over)),
|
||||||
|
Err(InviteError::BadFragment(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
// An unknown dictionary id is skipped, not fatal, so the dictionary can grow.
|
||||||
|
let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
|
||||||
|
unknown.extend_from_slice(&token);
|
||||||
|
let (decoded, relays) =
|
||||||
|
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
|
||||||
|
assert_eq!(decoded, token);
|
||||||
|
assert!(relays.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_round_trips_and_refuses_a_non_invite() {
|
||||||
|
let link_signer = Keys::generate();
|
||||||
|
let token = token16();
|
||||||
|
let relays = vec!["wss://a.example".to_owned()];
|
||||||
|
|
||||||
|
let url = build_invite_url(
|
||||||
|
"https://vectorapp.io/",
|
||||||
|
&link_signer.public_key(),
|
||||||
|
&token,
|
||||||
|
&relays,
|
||||||
|
)
|
||||||
|
.expect("builds");
|
||||||
|
|
||||||
|
let parsed = parse_link(&url).expect("parses");
|
||||||
|
assert_eq!(parsed.link_signer, link_signer.public_key());
|
||||||
|
assert_eq!(parsed.token, token);
|
||||||
|
assert_eq!(parsed.bootstrap_relays, relays);
|
||||||
|
|
||||||
|
let fragment = url.split('#').nth(1).expect("carries a fragment");
|
||||||
|
let bare = format!("{}#{fragment}", parsed.naddr);
|
||||||
|
let reparsed = parse_link(&bare).expect("parses the domain-agnostic form");
|
||||||
|
assert_eq!(reparsed.link_signer, link_signer.public_key());
|
||||||
|
assert_eq!(reparsed.token, token);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
parse_link("https://x/invite/#frag").is_err(),
|
||||||
|
"the naddr is not optional"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() {
|
||||||
|
let invite = bundle();
|
||||||
|
let link_signer = Keys::generate();
|
||||||
|
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
|
||||||
|
|
||||||
|
let event = build_bundle_event(&link_signer, &invite, &key).expect("builds");
|
||||||
|
assert_eq!(event.pubkey, link_signer.public_key());
|
||||||
|
|
||||||
|
match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") {
|
||||||
|
BundleState::Live(opened) => {
|
||||||
|
assert_eq!(opened.community_id, invite.community_id);
|
||||||
|
assert_eq!(opened.channels.len(), 1);
|
||||||
|
}
|
||||||
|
BundleState::Revoked => panic!("expected a live bundle"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let revocation = build_revocation(&link_signer).expect("builds");
|
||||||
|
assert!(matches!(
|
||||||
|
parse_bundle_event(&revocation, &link_signer.public_key(), &key),
|
||||||
|
Ok(BundleState::Revoked)
|
||||||
|
));
|
||||||
|
|
||||||
|
// The token is the only way in, and a squatter is a different coordinate.
|
||||||
|
assert!(
|
||||||
|
parse_bundle_event(
|
||||||
|
&event,
|
||||||
|
&link_signer.public_key(),
|
||||||
|
&invite_bundle_key(&[8u8; TOKEN_LEN])
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let squatter = Keys::generate();
|
||||||
|
assert!(matches!(
|
||||||
|
parse_bundle_event(&event, &squatter.public_key(), &key),
|
||||||
|
Err(InviteError::BadEvent(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() {
|
||||||
|
let invite = bundle();
|
||||||
|
let link_signer = Keys::generate();
|
||||||
|
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
|
||||||
|
let json = serde_json::to_string(&invite).expect("serializes");
|
||||||
|
let content = seal_bundle(&key, &json).expect("seals");
|
||||||
|
|
||||||
|
// The fetch filters on the author, so the empty `d` is pinned here: a
|
||||||
|
// signature-valid event of the same author at another `d` is not the bundle.
|
||||||
|
let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
|
||||||
|
.tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)])
|
||||||
|
.finalize(&link_signer)
|
||||||
|
.expect("signs");
|
||||||
|
assert!(matches!(
|
||||||
|
parse_bundle_event(&elsewhere, &link_signer.public_key(), &key),
|
||||||
|
Err(InviteError::BadEvent(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut forged = bundle();
|
||||||
|
forged.owner = Keys::generate().public_key();
|
||||||
|
assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch)));
|
||||||
|
assert!(matches!(
|
||||||
|
build_bundle_event(&link_signer, &forged, &key),
|
||||||
|
Err(InviteError::OwnerMismatch)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut malformed = bundle();
|
||||||
|
malformed.community_root = "not hex".to_owned();
|
||||||
|
assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_))));
|
||||||
|
|
||||||
|
let mut crowded = bundle();
|
||||||
|
crowded.channels = (0..=MAX_BUNDLE_CHANNELS)
|
||||||
|
.map(|_| ChannelGrant {
|
||||||
|
id: ChannelId::from_bytes([0x01; 32]),
|
||||||
|
key: None,
|
||||||
|
epoch: Epoch(0),
|
||||||
|
name: String::new(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(matches!(
|
||||||
|
crowded.validate(),
|
||||||
|
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() {
|
||||||
|
let inviter = Keys::generate();
|
||||||
|
let recipient = Keys::generate();
|
||||||
|
let invite = bundle();
|
||||||
|
|
||||||
|
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
|
||||||
|
assert_eq!(wrap.kind, Kind::GiftWrap);
|
||||||
|
assert_ne!(
|
||||||
|
wrap.pubkey,
|
||||||
|
inviter.public_key(),
|
||||||
|
"the wrap author is ephemeral"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]),
|
||||||
|
"the k tag is what makes an invite indexable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
|
||||||
|
assert_eq!(sender, inviter.public_key());
|
||||||
|
assert_eq!(opened.community_id, invite.community_id);
|
||||||
|
|
||||||
|
// Somebody else's wrap is not ours to open...
|
||||||
|
let stranger = Keys::generate();
|
||||||
|
assert!(unwrap_direct_invite(&wrap, &stranger).is_err());
|
||||||
|
|
||||||
|
// ...and a wrap that opens to some other kind is not an invite.
|
||||||
|
let rumor = EventBuilder::new(Kind::Custom(crate::chat::KIND_MESSAGE), "hello")
|
||||||
|
.finalize_unsigned(recipient.public_key());
|
||||||
|
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
|
||||||
|
.finalize(&recipient)
|
||||||
|
.expect("wraps");
|
||||||
|
assert!(matches!(
|
||||||
|
unwrap_direct_invite(&wrap, &recipient),
|
||||||
|
Err(InviteError::BadEvent(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
pub mod chat;
|
||||||
|
pub mod control;
|
||||||
|
pub mod derive;
|
||||||
|
pub mod edition;
|
||||||
|
pub mod guestbook;
|
||||||
|
pub mod invite;
|
||||||
|
pub mod list;
|
||||||
|
pub mod rekey;
|
||||||
|
pub mod roles;
|
||||||
|
pub mod store;
|
||||||
|
pub mod stream;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
pub use derive::GroupKey;
|
||||||
|
use rand::TryRng as _;
|
||||||
|
use rand::rngs::SysRng;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
|
||||||
|
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
|
||||||
|
|
||||||
|
macro_rules! hex_id {
|
||||||
|
($(#[$meta:meta])* $name:ident) => {
|
||||||
|
$(#[$meta])*
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct $name([u8; 32]);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
HEXLOWER.encode(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<[u8; 32]> for $name {
|
||||||
|
fn from(bytes: [u8; 32]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.to_hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}({})", stringify!($name), self.to_hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for $name {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self> {
|
||||||
|
Ok(Self(decode_hex_32(value)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for $name {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.to_hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for $name {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
value.parse().map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
hex_id! {
|
||||||
|
/// A self-certifying commitment to the owner's key, carried inside invites and
|
||||||
|
/// never on the wire.
|
||||||
|
CommunityId
|
||||||
|
}
|
||||||
|
|
||||||
|
hex_id! {
|
||||||
|
ChannelId
|
||||||
|
}
|
||||||
|
|
||||||
|
hex_id! {
|
||||||
|
/// Both a Role's entity coordinate and the field it repeats in its own content.
|
||||||
|
RoleId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
|
||||||
|
#[derive(
|
||||||
|
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
pub struct Epoch(pub u64);
|
||||||
|
|
||||||
|
impl From<u64> for Epoch {
|
||||||
|
fn from(value: u64) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Epoch> for u64 {
|
||||||
|
fn from(value: Epoch) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Epoch {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uppercase and other non-canonical spellings are rejected.
|
||||||
|
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||||
|
let bytes = HEXLOWER
|
||||||
|
.decode(value.as_bytes())
|
||||||
|
.map_err(|error| anyhow!("invalid hex: {error}"))?;
|
||||||
|
|
||||||
|
let decoded: [u8; 32] = bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
|
||||||
|
|
||||||
|
if HEXLOWER.encode(&decoded) != value {
|
||||||
|
bail!("hex must be lowercase and canonical");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
|
||||||
|
SysRng
|
||||||
|
.try_fill_bytes(bytes)
|
||||||
|
.map_err(|error| anyhow!("os rng: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn random_32() -> Result<[u8; 32]> {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
fill_random(&mut bytes)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::btree_map::Entry;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use nostr::nips::nip44::{self, Version};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::invite::{ChannelGrant, CommunityInvite};
|
||||||
|
use crate::stream::NIP44_MAX_PLAINTEXT;
|
||||||
|
use crate::{CommunityId, Epoch, Extra};
|
||||||
|
|
||||||
|
pub const KIND_COMMUNITY_LIST: u16 = 13302;
|
||||||
|
pub const MAX_MEMBERSHIPS: usize = 50;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ListError {
|
||||||
|
Kind(u16),
|
||||||
|
Crypto(String),
|
||||||
|
Json(String),
|
||||||
|
TooManyMemberships(usize),
|
||||||
|
Oversize(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ListError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
|
||||||
|
ListError::Crypto(error) => write!(f, "crypto: {error}"),
|
||||||
|
ListError::Json(error) => write!(f, "json: {error}"),
|
||||||
|
ListError::TooManyMemberships(count) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"list carries {count} memberships (cap {MAX_MEMBERSHIPS})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ListError::Oversize(len) => {
|
||||||
|
write!(f, "list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ListError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct JoinMaterial {
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
pub owner: PublicKey,
|
||||||
|
pub owner_salt: String,
|
||||||
|
pub community_root: String,
|
||||||
|
pub root_epoch: Epoch,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub control_pk: Option<PublicKey>,
|
||||||
|
/// Present only when the holder is staff.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub control_root: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub channels: Vec<ChannelGrant>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub relays: Vec<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityListEntry {
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
pub seed: JoinMaterial,
|
||||||
|
pub current: JoinMaterial,
|
||||||
|
pub added_at: u64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Tombstone {
|
||||||
|
pub community_id: CommunityId,
|
||||||
|
pub removed_at: u64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityList {
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub entries: Vec<CommunityListEntry>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tombstones: Vec<Tombstone>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityList {
|
||||||
|
pub fn is_live(&self, community_id: &CommunityId) -> bool {
|
||||||
|
let added = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.community_id == *community_id)
|
||||||
|
.map(|entry| entry.added_at);
|
||||||
|
|
||||||
|
match added {
|
||||||
|
None => false,
|
||||||
|
Some(added) => self
|
||||||
|
.tombstones
|
||||||
|
.iter()
|
||||||
|
.find(|tombstone| tombstone.community_id == *community_id)
|
||||||
|
.is_none_or(|tombstone| added > tombstone.removed_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fits(&self) -> Result<(), ListError> {
|
||||||
|
if self.entries.len() > MAX_MEMBERSHIPS {
|
||||||
|
return Err(ListError::TooManyMemberships(self.entries.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string(self).map_err(json_error)?;
|
||||||
|
|
||||||
|
if json.len() > NIP44_MAX_PLAINTEXT {
|
||||||
|
return Err(ListError::Oversize(json.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
|
||||||
|
JoinMaterial {
|
||||||
|
community_id: invite.community_id,
|
||||||
|
owner: invite.owner,
|
||||||
|
owner_salt: invite.owner_salt.clone(),
|
||||||
|
community_root: invite.community_root.clone(),
|
||||||
|
root_epoch: invite.root_epoch,
|
||||||
|
control_pk: invite.control_pk,
|
||||||
|
control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)),
|
||||||
|
channels: invite.channels.clone(),
|
||||||
|
relays: invite.relays.clone(),
|
||||||
|
name: invite.name.clone(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
|
||||||
|
let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new();
|
||||||
|
|
||||||
|
for entry in held.entries.into_iter().chain(incoming.entries) {
|
||||||
|
match entries.entry(entry.community_id) {
|
||||||
|
Entry::Vacant(slot) => {
|
||||||
|
slot.insert(entry);
|
||||||
|
}
|
||||||
|
Entry::Occupied(mut slot) => merge_entry(slot.get_mut(), entry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tombstones: BTreeMap<CommunityId, Tombstone> = BTreeMap::new();
|
||||||
|
|
||||||
|
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
|
||||||
|
match tombstones.entry(tombstone.community_id) {
|
||||||
|
Entry::Vacant(slot) => {
|
||||||
|
slot.insert(tombstone);
|
||||||
|
}
|
||||||
|
Entry::Occupied(mut slot) => {
|
||||||
|
let held = slot.get_mut();
|
||||||
|
held.removed_at = held.removed_at.max(tombstone.removed_at);
|
||||||
|
union(&mut held.extra, tombstone.extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut extra = held.extra;
|
||||||
|
union(&mut extra, incoming.extra);
|
||||||
|
|
||||||
|
CommunityList {
|
||||||
|
entries: entries.into_values().collect(),
|
||||||
|
tombstones: tombstones.into_values().collect(),
|
||||||
|
extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> {
|
||||||
|
list.fits()?;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||||
|
let content = nip44::encrypt(
|
||||||
|
keys.secret_key(),
|
||||||
|
&keys.public_key(),
|
||||||
|
json.as_bytes(),
|
||||||
|
Version::V2,
|
||||||
|
)
|
||||||
|
.map_err(crypto_error)?;
|
||||||
|
|
||||||
|
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
|
||||||
|
.finalize(keys)
|
||||||
|
.map_err(crypto_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> {
|
||||||
|
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
|
||||||
|
return Err(ListError::Kind(event.kind.as_u16()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
|
||||||
|
.map_err(crypto_error)?;
|
||||||
|
|
||||||
|
serde_json::from_str(&json).map_err(json_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Snapshot {
|
||||||
|
Seed,
|
||||||
|
Current,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
|
||||||
|
held.added_at = held.added_at.max(incoming.added_at);
|
||||||
|
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
|
||||||
|
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
|
||||||
|
union(&mut held.extra, incoming.extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick<'a>(
|
||||||
|
held: &'a JoinMaterial,
|
||||||
|
incoming: &'a JoinMaterial,
|
||||||
|
which: Snapshot,
|
||||||
|
) -> &'a JoinMaterial {
|
||||||
|
let preferred = match which {
|
||||||
|
Snapshot::Seed => incoming.root_epoch < held.root_epoch,
|
||||||
|
Snapshot::Current => incoming.root_epoch > held.root_epoch,
|
||||||
|
};
|
||||||
|
|
||||||
|
if preferred {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
if incoming.root_epoch == held.root_epoch && canonical(incoming) < canonical(held) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
held
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn union(into: &mut Extra, other: Extra) {
|
||||||
|
for (key, value) in other {
|
||||||
|
let replace = match into.get(&key) {
|
||||||
|
Some(existing) => canonical(&value) < canonical(existing),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if replace {
|
||||||
|
into.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
|
||||||
|
serde_json::to_string(value).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_error(error: serde_json::Error) -> ListError {
|
||||||
|
ListError::Json(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crypto_error(error: impl fmt::Display) -> ListError {
|
||||||
|
ListError::Crypto(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn id(byte: u8) -> CommunityId {
|
||||||
|
CommunityId::from_bytes([byte; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn material(
|
||||||
|
community_id: CommunityId,
|
||||||
|
owner: PublicKey,
|
||||||
|
name: &str,
|
||||||
|
epoch: u64,
|
||||||
|
) -> JoinMaterial {
|
||||||
|
JoinMaterial {
|
||||||
|
community_id,
|
||||||
|
owner,
|
||||||
|
owner_salt: "33".repeat(32),
|
||||||
|
community_root: "44".repeat(32),
|
||||||
|
root_epoch: Epoch(epoch),
|
||||||
|
control_pk: None,
|
||||||
|
control_root: None,
|
||||||
|
channels: vec![],
|
||||||
|
relays: vec!["wss://relay.example".to_owned()],
|
||||||
|
name: name.to_owned(),
|
||||||
|
extra: Extra::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(
|
||||||
|
community_id: CommunityId,
|
||||||
|
seed: JoinMaterial,
|
||||||
|
current: JoinMaterial,
|
||||||
|
added_at: u64,
|
||||||
|
) -> CommunityListEntry {
|
||||||
|
CommunityListEntry {
|
||||||
|
community_id,
|
||||||
|
seed,
|
||||||
|
current,
|
||||||
|
added_at,
|
||||||
|
extra: Extra::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(entries: Vec<CommunityListEntry>) -> CommunityList {
|
||||||
|
CommunityList {
|
||||||
|
entries,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn removal(community_id: CommunityId, removed_at: u64) -> CommunityList {
|
||||||
|
CommunityList {
|
||||||
|
tombstones: vec![Tombstone {
|
||||||
|
community_id,
|
||||||
|
removed_at,
|
||||||
|
extra: Extra::default(),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let older = material(id(0x11), owner, "Room", 1);
|
||||||
|
let newer = material(id(0x11), owner, "Room", 3);
|
||||||
|
|
||||||
|
let a = list(vec![entry(id(0x11), older.clone(), newer.clone(), 5_000)]);
|
||||||
|
let b = list(vec![entry(id(0x11), newer, older, 5_000)]);
|
||||||
|
|
||||||
|
for merged in [merge(a.clone(), b.clone()), merge(b, a)] {
|
||||||
|
let merged = merged.entries.first().expect("one membership");
|
||||||
|
assert_eq!(
|
||||||
|
merged.seed.root_epoch,
|
||||||
|
Epoch(1),
|
||||||
|
"seed anchors the earliest epoch held"
|
||||||
|
);
|
||||||
|
assert_eq!(merged.current.root_epoch, Epoch(3));
|
||||||
|
assert_eq!(merged.added_at, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An epoch tie breaks on the whole snapshot's bytes, and does so for both
|
||||||
|
// orders, so two devices never flap competing republishes.
|
||||||
|
let alpha = material(id(0x11), owner, "Alpha", 2);
|
||||||
|
let beta = material(id(0x11), owner, "Beta", 2);
|
||||||
|
let a = list(vec![entry(id(0x11), alpha.clone(), alpha, 1)]);
|
||||||
|
let b = list(vec![entry(id(0x11), beta.clone(), beta, 1)]);
|
||||||
|
|
||||||
|
let first = merge(a.clone(), b.clone());
|
||||||
|
assert_eq!(first, merge(b, a));
|
||||||
|
assert_eq!(first.entries[0].current.name, "Alpha");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() {
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let joined = entry(
|
||||||
|
id(0x11),
|
||||||
|
material(id(0x11), owner, "Room", 0),
|
||||||
|
material(id(0x11), owner, "Room", 0),
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
let left = merge(list(vec![joined.clone()]), removal(id(0x11), 6_000));
|
||||||
|
assert!(!left.is_live(&id(0x11)));
|
||||||
|
assert_eq!(
|
||||||
|
left.entries.len(),
|
||||||
|
1,
|
||||||
|
"a retired entry stays in the document"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A stale device re-merging the entry cannot resurrect it.
|
||||||
|
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
|
||||||
|
|
||||||
|
// A re-join genuinely newer than the removal does.
|
||||||
|
let rejoined = list(vec![entry(
|
||||||
|
id(0x11),
|
||||||
|
material(id(0x11), owner, "Room", 0),
|
||||||
|
material(id(0x11), owner, "Room", 0),
|
||||||
|
7_000,
|
||||||
|
)]);
|
||||||
|
let live = merge(left, rejoined);
|
||||||
|
assert!(live.is_live(&id(0x11)));
|
||||||
|
|
||||||
|
// And the older removal is not re-applied on top of it.
|
||||||
|
assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_device_reconstructs_membership_from_13302() {
|
||||||
|
let me = Keys::generate();
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let mine = CommunityList {
|
||||||
|
entries: vec![
|
||||||
|
entry(
|
||||||
|
id(0x11),
|
||||||
|
material(id(0x11), owner, "Room", 1),
|
||||||
|
material(id(0x11), owner, "Room", 4),
|
||||||
|
AT,
|
||||||
|
),
|
||||||
|
entry(
|
||||||
|
id(0x22),
|
||||||
|
material(id(0x22), owner, "Other", 0),
|
||||||
|
material(id(0x22), owner, "Other", 0),
|
||||||
|
AT + 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tombstones: vec![Tombstone {
|
||||||
|
community_id: id(0x33),
|
||||||
|
removed_at: AT,
|
||||||
|
extra: Extra::default(),
|
||||||
|
}],
|
||||||
|
extra: Extra::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = build_list_event(&me, &mine).expect("builds");
|
||||||
|
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
|
||||||
|
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine);
|
||||||
|
assert!(
|
||||||
|
!parse_list_event(&me, &event)
|
||||||
|
.expect("parses")
|
||||||
|
.is_live(&id(0x33))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only the member's own keys open it, and an unreadable list is "no news".
|
||||||
|
let stranger = Keys::generate();
|
||||||
|
assert!(parse_list_event(&stranger, &event).is_err());
|
||||||
|
|
||||||
|
// Unknown fields survive the round trip, so a republish cannot wipe them.
|
||||||
|
let mut held = mine.clone();
|
||||||
|
held.extra
|
||||||
|
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
|
||||||
|
held.entries[0]
|
||||||
|
.current
|
||||||
|
.extra
|
||||||
|
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
|
||||||
|
let rebuilt =
|
||||||
|
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
|
||||||
|
assert_eq!(rebuilt, held);
|
||||||
|
|
||||||
|
// The write gate refuses an over-cap or oversized List before publishing.
|
||||||
|
let crowded = list(
|
||||||
|
(0..=MAX_MEMBERSHIPS)
|
||||||
|
.map(|index| {
|
||||||
|
let community_id = CommunityId::from_bytes([index as u8; 32]);
|
||||||
|
entry(
|
||||||
|
community_id,
|
||||||
|
material(community_id, owner, "Room", 0),
|
||||||
|
material(community_id, owner, "Room", 0),
|
||||||
|
AT,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
build_list_event(&me, &crowded),
|
||||||
|
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
|
||||||
|
));
|
||||||
|
|
||||||
|
let oversized = list(vec![entry(
|
||||||
|
id(0x11),
|
||||||
|
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
|
||||||
|
material(id(0x11), owner, "Room", 0),
|
||||||
|
AT,
|
||||||
|
)]);
|
||||||
|
assert!(matches!(oversized.fits(), Err(ListError::Oversize(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const AT: u64 = 1_719_800_000_000;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::chat::{self, ChatRumor, plane_keys};
|
||||||
|
use crate::control::{
|
||||||
|
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||||
|
};
|
||||||
|
use crate::derive::control_signer_group_key;
|
||||||
|
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
|
||||||
|
use crate::stream::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||||
|
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||||
|
|
||||||
|
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||||
|
|
||||||
|
const MAX_PAGES: usize = 8;
|
||||||
|
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
|
||||||
|
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||||
|
const MARK_VALUE: &str = "concord";
|
||||||
|
const WRAP_TAG: &str = "e";
|
||||||
|
const KIND_TAG: &str = "k";
|
||||||
|
const STATE_PREFIX: &str = "concord/";
|
||||||
|
|
||||||
|
pub async fn cache_rumor(
|
||||||
|
database: &dyn NostrDatabase,
|
||||||
|
channel: &ChannelId,
|
||||||
|
opened: &OpenedStream,
|
||||||
|
) -> Result<()> {
|
||||||
|
let tags = vec![
|
||||||
|
Tag::identifier(opened.rumor_id),
|
||||||
|
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
|
||||||
|
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
|
||||||
|
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
|
||||||
|
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
|
||||||
|
Tag::public_key(opened.author),
|
||||||
|
];
|
||||||
|
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||||
|
.tags(tags)
|
||||||
|
.custom_created_at(at)
|
||||||
|
.finalize_async(&*LOCAL_KEYS)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
database.save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn query_rumors(
|
||||||
|
database: &dyn NostrDatabase,
|
||||||
|
channel: &ChannelId,
|
||||||
|
until: Option<Timestamp>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<UnsignedEvent>> {
|
||||||
|
let mut filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||||
|
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||||
|
|
||||||
|
if let Some(until) = until {
|
||||||
|
filter = filter.until(until);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
||||||
|
for event in database.query(filter).await? {
|
||||||
|
let Some(rumor_id) = event.tags.identifier() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match newest.get(&rumor_id) {
|
||||||
|
Some(existing) if existing.created_at >= event.created_at => {}
|
||||||
|
_ => {
|
||||||
|
newest.insert(rumor_id, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events: Vec<Event> = newest.into_values().collect();
|
||||||
|
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
|
||||||
|
events.truncate(limit);
|
||||||
|
|
||||||
|
let mut rumors = Vec::with_capacity(events.len());
|
||||||
|
for event in events {
|
||||||
|
let rumor = UnsignedEvent::from_json(event.content)
|
||||||
|
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
|
||||||
|
rumors.push(rumor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rumors)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelKeyRef {
|
||||||
|
pub id: ChannelId,
|
||||||
|
pub name: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub epoch: Epoch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One local document per community, keyed by `concord/<community_id>`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CommunityState {
|
||||||
|
pub id: CommunityId,
|
||||||
|
pub owner: PublicKey,
|
||||||
|
pub owner_salt: [u8; 32],
|
||||||
|
pub community_root: [u8; 32],
|
||||||
|
pub root_epoch: Epoch,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub control_root: Option<[u8; 32]>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub channels: Vec<ChannelKeyRef>,
|
||||||
|
pub relays: Vec<RelayUrl>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub heads: Vec<EntityHead>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
pub banned: BTreeSet<PublicKey>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dissolved: bool,
|
||||||
|
pub added_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommunityState {
|
||||||
|
pub fn from_genesis(
|
||||||
|
genesis: &CommunityGenesis,
|
||||||
|
editions: &[ParsedEdition],
|
||||||
|
added_at_ms: u64,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
let mut heads = Vec::with_capacity(editions.len());
|
||||||
|
let mut relays = Vec::new();
|
||||||
|
|
||||||
|
for edition in editions {
|
||||||
|
heads.push(EntityHead {
|
||||||
|
entity: edition.entity,
|
||||||
|
version: edition.version,
|
||||||
|
self_hash: edition.self_hash,
|
||||||
|
rumor_id: edition.rumor_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
match edition.subkind.as_str() {
|
||||||
|
vsk::COMMUNITY_METADATA => {
|
||||||
|
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||||
|
relays.extend(
|
||||||
|
metadata
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
vsk::CHANNEL_METADATA => {
|
||||||
|
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||||
|
channels.push(ChannelKeyRef {
|
||||||
|
id: ChannelId::from_bytes(edition.entity),
|
||||||
|
name: metadata.name,
|
||||||
|
private: metadata.private,
|
||||||
|
epoch: ROOT_EPOCH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let control_pks = BTreeMap::from([(
|
||||||
|
ROOT_EPOCH.0,
|
||||||
|
control_signer_group_key(
|
||||||
|
&genesis.control_root,
|
||||||
|
&genesis.identity.community_id,
|
||||||
|
ROOT_EPOCH,
|
||||||
|
)?
|
||||||
|
.pk(),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: genesis.identity.community_id,
|
||||||
|
owner: genesis.identity.owner,
|
||||||
|
owner_salt: genesis.identity.owner_salt,
|
||||||
|
community_root: genesis.community_root,
|
||||||
|
root_epoch: ROOT_EPOCH,
|
||||||
|
control_root: Some(genesis.control_root),
|
||||||
|
control_pks,
|
||||||
|
channels,
|
||||||
|
relays,
|
||||||
|
heads,
|
||||||
|
banned: BTreeSet::new(),
|
||||||
|
dissolved: false,
|
||||||
|
added_at_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identifier(&self) -> String {
|
||||||
|
state_identifier(&self.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn floors(&self) -> Floors {
|
||||||
|
self.heads
|
||||||
|
.iter()
|
||||||
|
.map(|head| (head.entity, head.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||||
|
self.heads = fold.floors.values().cloned().collect();
|
||||||
|
self.banned = fold.banned.clone();
|
||||||
|
|
||||||
|
if let Some(community) = &fold.community {
|
||||||
|
self.relays = community
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, metadata) in &fold.channels {
|
||||||
|
if metadata.deleted.unwrap_or(false) {
|
||||||
|
self.channels.retain(|channel| channel.id != *id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||||
|
Some(channel) => {
|
||||||
|
channel.name = metadata.name.clone();
|
||||||
|
|
||||||
|
if !metadata.private {
|
||||||
|
channel.private = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||||
|
id: *id,
|
||||||
|
name: metadata.name.clone(),
|
||||||
|
private: false,
|
||||||
|
epoch: self.root_epoch,
|
||||||
|
}),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_identifier(id: &CommunityId) -> String {
|
||||||
|
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||||
|
where
|
||||||
|
D: NostrDatabase,
|
||||||
|
{
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
|
.tags([Tag::identifier(state.identifier())])
|
||||||
|
.finalize_async(&*LOCAL_KEYS)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
database.save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||||
|
where
|
||||||
|
D: NostrDatabase,
|
||||||
|
{
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(state_identifier(id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
match database.query(filter).await?.into_iter().next() {
|
||||||
|
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn backfill(
|
||||||
|
client: &Client,
|
||||||
|
database: &dyn NostrDatabase,
|
||||||
|
channel: &ChannelId,
|
||||||
|
held: &[(Epoch, [u8; 32])],
|
||||||
|
until: Option<Timestamp>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<ChatRumor>> {
|
||||||
|
let planes = plane_keys(held, channel)?;
|
||||||
|
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||||
|
|
||||||
|
let mut cursor = until;
|
||||||
|
let mut seen: BTreeSet<EventId> = BTreeSet::new();
|
||||||
|
let mut found: Vec<ChatRumor> = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..MAX_PAGES {
|
||||||
|
let page = fetch_page(client, &authors, cursor, limit).await?;
|
||||||
|
|
||||||
|
if page.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
|
||||||
|
|
||||||
|
for (opened, rumor) in fresh {
|
||||||
|
cache_rumor(database, channel, &opened).await?;
|
||||||
|
found.push(rumor);
|
||||||
|
}
|
||||||
|
|
||||||
|
match next {
|
||||||
|
Some(next) => cursor = Some(next),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||||
|
found.truncate(limit);
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(
|
||||||
|
page: &BTreeSet<Event>,
|
||||||
|
planes: &[(Epoch, GroupKey)],
|
||||||
|
channel: &ChannelId,
|
||||||
|
cursor: Option<Timestamp>,
|
||||||
|
limit: usize,
|
||||||
|
seen: &mut BTreeSet<EventId>,
|
||||||
|
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
|
||||||
|
let mut fresh = Vec::new();
|
||||||
|
|
||||||
|
for wrap in page {
|
||||||
|
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((opened, rumor)) = chat::open(wrap, group, channel, *epoch) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if seen.insert(rumor.id) {
|
||||||
|
fresh.push((opened, rumor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fresh.is_empty() || page.len() < limit {
|
||||||
|
return (fresh, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldest = page.iter().map(|event| event.created_at).min();
|
||||||
|
|
||||||
|
match oldest {
|
||||||
|
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
|
||||||
|
_ => (fresh, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_page(
|
||||||
|
client: &Client,
|
||||||
|
authors: &[PublicKey],
|
||||||
|
until: Option<Timestamp>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<BTreeSet<Event>> {
|
||||||
|
let mut filter = Filter::new()
|
||||||
|
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||||
|
.authors(authors.iter().copied())
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if let Some(until) = until {
|
||||||
|
filter = filter.until(until);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(client.fetch_events(filter).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use nostr_memory::MemoryDatabase;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::Epoch;
|
||||||
|
use crate::chat::{build_message, seal_rumor};
|
||||||
|
use crate::derive::channel_group_key;
|
||||||
|
use crate::stream::{
|
||||||
|
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||||
|
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||||
|
|
||||||
|
/// What a relay does with an inclusive `until` and a `limit`.
|
||||||
|
fn serve_page(
|
||||||
|
relay: &BTreeSet<Event>,
|
||||||
|
cursor: Option<Timestamp>,
|
||||||
|
limit: usize,
|
||||||
|
) -> BTreeSet<Event> {
|
||||||
|
let mut events: Vec<Event> = relay
|
||||||
|
.iter()
|
||||||
|
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
events.sort_by_key(|event| Reverse(event.created_at));
|
||||||
|
events.truncate(limit);
|
||||||
|
events.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_pages_back_across_a_rekey() {
|
||||||
|
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||||
|
let author = Keys::generate();
|
||||||
|
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
|
||||||
|
let planes = plane_keys(&held, &channel).expect("derives");
|
||||||
|
|
||||||
|
// Three messages a second apart: a page boundary falls between each.
|
||||||
|
let base = 1_700_000_000_000;
|
||||||
|
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
||||||
|
|
||||||
|
for (content, secret, epoch, at_ms) in [
|
||||||
|
("before the rekey", &SECRET, Epoch(0), base),
|
||||||
|
("still before", &SECRET, Epoch(0), base + 1_000),
|
||||||
|
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
||||||
|
] {
|
||||||
|
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
||||||
|
let rumor = build_message(author.public_key(), &channel, epoch, content, None, at_ms);
|
||||||
|
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut found = Vec::new();
|
||||||
|
let mut cursor = None;
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
let page = serve_page(&relay, cursor, 2);
|
||||||
|
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
|
||||||
|
|
||||||
|
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
|
||||||
|
|
||||||
|
match next {
|
||||||
|
Some(next) => cursor = Some(next),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||||
|
|
||||||
|
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
contents,
|
||||||
|
["after the rekey", "still before", "before the rekey"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumors_read_back_after_a_restart() {
|
||||||
|
let database = MemoryDatabase::unbounded();
|
||||||
|
let channel = ChannelId::from_bytes([0xabu8; 32]);
|
||||||
|
let author = Keys::generate();
|
||||||
|
|
||||||
|
smol::block_on(async {
|
||||||
|
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||||
|
|
||||||
|
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
|
||||||
|
let rumor = build_rumor_ms(
|
||||||
|
9,
|
||||||
|
author.public_key(),
|
||||||
|
content,
|
||||||
|
channel_binding_tags(&channel, Epoch(0)),
|
||||||
|
at_ms,
|
||||||
|
);
|
||||||
|
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
|
||||||
|
let (wrap, _) = wrap_seal(
|
||||||
|
&seal,
|
||||||
|
&group,
|
||||||
|
KIND_WRAP,
|
||||||
|
Timestamp::from_secs(at_ms / 1000),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.expect("wraps");
|
||||||
|
|
||||||
|
let opened = open_wrap(&wrap, &group).expect("opens");
|
||||||
|
cache_rumor(&database, &channel, &opened)
|
||||||
|
.await
|
||||||
|
.expect("caches");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group key is gone; only the local cache stands in for it.
|
||||||
|
let rumors = query_rumors(&database, &channel, None, 10)
|
||||||
|
.await
|
||||||
|
.expect("queries");
|
||||||
|
assert_eq!(rumors.len(), 2, "both messages come back");
|
||||||
|
assert_eq!(rumors[0].content, "second", "newest first");
|
||||||
|
assert_eq!(rumors[1].content, "first");
|
||||||
|
|
||||||
|
// A page boundary in message time, not in cache time.
|
||||||
|
let until = Timestamp::from_secs(1_500);
|
||||||
|
let page = query_rumors(&database, &channel, Some(until), 10)
|
||||||
|
.await
|
||||||
|
.expect("queries");
|
||||||
|
assert_eq!(page.len(), 1);
|
||||||
|
assert_eq!(page[0].content, "first");
|
||||||
|
|
||||||
|
let capped = query_rumors(&database, &channel, None, 1)
|
||||||
|
.await
|
||||||
|
.expect("queries");
|
||||||
|
assert_eq!(capped.len(), 1);
|
||||||
|
assert_eq!(capped[0].content, "second");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use data_encoding::BASE64;
|
||||||
|
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
|
||||||
|
use nostr_sdk::prelude::{
|
||||||
|
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
|
||||||
|
UnsignedEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::derive::GroupKey;
|
||||||
|
use crate::{ChannelId, Epoch};
|
||||||
|
|
||||||
|
pub const KIND_WRAP: u16 = 1059;
|
||||||
|
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
|
||||||
|
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
|
||||||
|
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
|
||||||
|
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
|
||||||
|
|
||||||
|
const TAG_MS: &str = "ms";
|
||||||
|
const TAG_CHANNEL: &str = "channel";
|
||||||
|
const TAG_EPOCH: &str = "epoch";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SealForm {
|
||||||
|
Encrypted,
|
||||||
|
Plaintext,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SealForm {
|
||||||
|
pub fn kind(self) -> u16 {
|
||||||
|
match self {
|
||||||
|
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
|
||||||
|
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_kind(kind: u16) -> Option<Self> {
|
||||||
|
match kind {
|
||||||
|
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
|
||||||
|
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum StreamError {
|
||||||
|
Sign(String),
|
||||||
|
Encrypt(String),
|
||||||
|
Decrypt(String),
|
||||||
|
Parse(String),
|
||||||
|
Oversize(usize),
|
||||||
|
BadWrapKind(u16),
|
||||||
|
WrongStream,
|
||||||
|
BadWrapSignature,
|
||||||
|
BadSealKind(u16),
|
||||||
|
BadSealSignature,
|
||||||
|
AuthorMismatch,
|
||||||
|
BadRumorId,
|
||||||
|
BadMs,
|
||||||
|
ChannelMismatch,
|
||||||
|
EpochMismatch,
|
||||||
|
MissingTag(&'static str),
|
||||||
|
DuplicateTag(&'static str),
|
||||||
|
NotRewrappable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for StreamError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
StreamError::Sign(error) => write!(f, "sign: {error}"),
|
||||||
|
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
|
||||||
|
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
|
||||||
|
StreamError::Parse(error) => write!(f, "parse: {error}"),
|
||||||
|
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
|
||||||
|
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
|
||||||
|
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
|
||||||
|
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
|
||||||
|
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
|
||||||
|
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
|
||||||
|
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
|
||||||
|
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
|
||||||
|
StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"),
|
||||||
|
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
|
||||||
|
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
|
||||||
|
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
|
||||||
|
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
|
||||||
|
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StreamError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenedStream {
|
||||||
|
pub rumor_id: EventId,
|
||||||
|
pub author: PublicKey,
|
||||||
|
pub seal_form: SealForm,
|
||||||
|
pub seal: Event,
|
||||||
|
pub wrapper_id: EventId,
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub rumor: UnsignedEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_ms(at_ms: u64) -> (u64, u16) {
|
||||||
|
(at_ms / 1000, (at_ms % 1000) as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a rumor carrying a full epoch-ms time: `created_at`
|
||||||
|
/// holds the seconds and an `["ms", 0..=999]` tag the remainder.
|
||||||
|
pub fn build_rumor_ms(
|
||||||
|
kind: u16,
|
||||||
|
author: PublicKey,
|
||||||
|
content: &str,
|
||||||
|
mut tags: Vec<Tag>,
|
||||||
|
at_ms: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let (seconds, offset) = split_ms(at_ms);
|
||||||
|
tags.push(Tag::custom(TAG_MS, [offset.to_string()]));
|
||||||
|
build_rumor_secs(kind, author, content, tags, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a rumor with a plain seconds timestamp and no `ms` tag.
|
||||||
|
pub fn build_rumor_secs(
|
||||||
|
kind: u16,
|
||||||
|
author: PublicKey,
|
||||||
|
content: &str,
|
||||||
|
tags: Vec<Tag>,
|
||||||
|
at_secs: u64,
|
||||||
|
) -> UnsignedEvent {
|
||||||
|
let mut rumor = UnsignedEvent::new(
|
||||||
|
author,
|
||||||
|
Timestamp::from_secs(at_secs),
|
||||||
|
Kind::Custom(kind),
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
rumor.ensure_id();
|
||||||
|
rumor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
|
||||||
|
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
|
||||||
|
let mut tag: Option<Option<String>> = None;
|
||||||
|
|
||||||
|
for candidate in rumor.tags.iter() {
|
||||||
|
let fields = candidate.as_slice();
|
||||||
|
if fields.first().map(String::as_str) == Some(TAG_MS) {
|
||||||
|
tag = Some(fields.get(1).cloned());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(raw) = tag else {
|
||||||
|
return Ok(seconds);
|
||||||
|
};
|
||||||
|
let raw = raw.ok_or(StreamError::BadMs)?;
|
||||||
|
|
||||||
|
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
|
return Err(StreamError::BadMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
|
||||||
|
|
||||||
|
if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) {
|
||||||
|
return Err(StreamError::BadMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(seconds.saturating_add(offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seal_content(
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
form: SealForm,
|
||||||
|
group: &GroupKey,
|
||||||
|
) -> Result<String, StreamError> {
|
||||||
|
let json = rumor.as_json();
|
||||||
|
check_plaintext_cap(json.len())?;
|
||||||
|
|
||||||
|
match form {
|
||||||
|
SealForm::Plaintext => Ok(json),
|
||||||
|
SealForm::Encrypted => seal_bytes(group.conversation(), json.as_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
|
||||||
|
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u8>, StreamError> {
|
||||||
|
let payload = BASE64
|
||||||
|
.decode(content.as_bytes())
|
||||||
|
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||||
|
|
||||||
|
decrypt_to_bytes(conversation, &payload)
|
||||||
|
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_seal(
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
form: SealForm,
|
||||||
|
group: &GroupKey,
|
||||||
|
author: &Keys,
|
||||||
|
) -> Result<Event, StreamError> {
|
||||||
|
let content = seal_content(rumor, form, group)?;
|
||||||
|
EventBuilder::new(Kind::Custom(form.kind()), content)
|
||||||
|
.custom_created_at(rumor.created_at)
|
||||||
|
.finalize(author)
|
||||||
|
.map_err(|error| StreamError::Sign(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wrap_seal(
|
||||||
|
seal: &Event,
|
||||||
|
group: &GroupKey,
|
||||||
|
wrap_kind: u16,
|
||||||
|
at: Timestamp,
|
||||||
|
extra: &[Tag],
|
||||||
|
) -> Result<(Event, Keys), StreamError> {
|
||||||
|
wrap_seal_with(
|
||||||
|
seal,
|
||||||
|
group.conversation(),
|
||||||
|
group.keys(),
|
||||||
|
wrap_kind,
|
||||||
|
at,
|
||||||
|
extra,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wrap_seal_with(
|
||||||
|
seal: &Event,
|
||||||
|
conversation: &ConversationKey,
|
||||||
|
signer: &Keys,
|
||||||
|
wrap_kind: u16,
|
||||||
|
at: Timestamp,
|
||||||
|
extra: &[Tag],
|
||||||
|
) -> Result<(Event, Keys), StreamError> {
|
||||||
|
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
||||||
|
return Err(StreamError::BadWrapKind(wrap_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = seal.as_json();
|
||||||
|
check_plaintext_cap(json.len())?;
|
||||||
|
|
||||||
|
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
|
||||||
|
let ephemeral = Keys::generate();
|
||||||
|
|
||||||
|
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
|
||||||
|
tags.extend_from_slice(extra);
|
||||||
|
|
||||||
|
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
|
||||||
|
.tags(tags)
|
||||||
|
.custom_created_at(at)
|
||||||
|
.finalize(signer)
|
||||||
|
.map_err(|error| StreamError::Sign(error.to_string()))?;
|
||||||
|
|
||||||
|
Ok((wrap, ephemeral))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rewrap_seal(
|
||||||
|
seal: &Event,
|
||||||
|
read: &GroupKey,
|
||||||
|
signer: &GroupKey,
|
||||||
|
at: Timestamp,
|
||||||
|
) -> Result<(Event, Keys), StreamError> {
|
||||||
|
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
|
||||||
|
return Err(StreamError::NotRewrappable);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
|
||||||
|
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_wrap_at(
|
||||||
|
wrap: &Event,
|
||||||
|
address: &PublicKey,
|
||||||
|
conversation: &ConversationKey,
|
||||||
|
verify_wrap_signature: bool,
|
||||||
|
) -> Result<OpenedStream, StreamError> {
|
||||||
|
let wrap_kind = wrap.kind.as_u16();
|
||||||
|
|
||||||
|
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
||||||
|
return Err(StreamError::BadWrapKind(wrap_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
if wrap.pubkey != *address {
|
||||||
|
return Err(StreamError::WrongStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
if verify_wrap_signature && wrap.verify().is_err() {
|
||||||
|
return Err(StreamError::BadWrapSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?)
|
||||||
|
.map_err(|error| StreamError::Parse(error.to_string()))?;
|
||||||
|
let seal_kind = seal.kind.as_u16();
|
||||||
|
let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?;
|
||||||
|
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
|
||||||
|
|
||||||
|
let rumor_json = match seal_form {
|
||||||
|
SealForm::Plaintext => seal.content.clone(),
|
||||||
|
SealForm::Encrypted => decode_content(conversation, &seal.content)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes())
|
||||||
|
.map_err(|error| StreamError::Parse(error.to_string()))?;
|
||||||
|
|
||||||
|
if rumor.pubkey != seal.pubkey {
|
||||||
|
return Err(StreamError::AuthorMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let computed = rumor.compute_id();
|
||||||
|
if let Some(claimed) = rumor.id
|
||||||
|
&& claimed != computed
|
||||||
|
{
|
||||||
|
return Err(StreamError::BadRumorId);
|
||||||
|
}
|
||||||
|
rumor.id = Some(computed);
|
||||||
|
|
||||||
|
let at_ms = resolve_ms_strict(&rumor)?;
|
||||||
|
|
||||||
|
Ok(OpenedStream {
|
||||||
|
rumor_id: computed,
|
||||||
|
author: seal.pubkey,
|
||||||
|
seal_form,
|
||||||
|
seal,
|
||||||
|
wrapper_id: wrap.id,
|
||||||
|
at_ms,
|
||||||
|
rumor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag> {
|
||||||
|
vec![
|
||||||
|
Tag::custom(TAG_CHANNEL, [channel.to_hex()]),
|
||||||
|
Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_channel_binding(
|
||||||
|
rumor: &UnsignedEvent,
|
||||||
|
channel: &ChannelId,
|
||||||
|
epoch: Epoch,
|
||||||
|
) -> Result<(), StreamError> {
|
||||||
|
match unique_tag(rumor, TAG_CHANNEL)? {
|
||||||
|
Some(value) if value == channel.to_hex() => {}
|
||||||
|
Some(_) => return Err(StreamError::ChannelMismatch),
|
||||||
|
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
|
||||||
|
}
|
||||||
|
|
||||||
|
match unique_tag(rumor, TAG_EPOCH)? {
|
||||||
|
Some(value) if value == epoch.0.to_string() => {}
|
||||||
|
Some(_) => return Err(StreamError::EpochMismatch),
|
||||||
|
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
|
||||||
|
let mut nonce = [0u8; 32];
|
||||||
|
|
||||||
|
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||||
|
|
||||||
|
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
|
||||||
|
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
|
||||||
|
let plaintext = open_bytes(conversation, content)?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
|
||||||
|
if len > NIP44_MAX_PLAINTEXT {
|
||||||
|
return Err(StreamError::Oversize(len));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
|
||||||
|
let mut found: Option<String> = None;
|
||||||
|
|
||||||
|
for tag in rumor.tags.iter() {
|
||||||
|
let fields = tag.as_slice();
|
||||||
|
if fields.len() >= 2 && fields[0] == name {
|
||||||
|
if found.is_some() {
|
||||||
|
return Err(StreamError::DuplicateTag(name));
|
||||||
|
}
|
||||||
|
found = Some(fields[1].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::derive::channel_group_key;
|
||||||
|
|
||||||
|
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||||
|
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
|
||||||
|
|
||||||
|
fn channel() -> ChannelId {
|
||||||
|
ChannelId::from_bytes([0xabu8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group(epoch: u64) -> GroupKey {
|
||||||
|
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
|
||||||
|
wrap.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
|
||||||
|
.and_then(|tag| tag.as_slice().get(1).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
|
||||||
|
build_rumor_ms(
|
||||||
|
9,
|
||||||
|
author,
|
||||||
|
content,
|
||||||
|
channel_binding_tags(&channel(), Epoch(0)),
|
||||||
|
at_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
|
||||||
|
build_seal(rumor, form, &group(0), author).expect("seals")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
|
||||||
|
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
|
||||||
|
.expect("wraps")
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
|
||||||
|
let rumor = bound_rumor(content, author.public_key(), at_ms);
|
||||||
|
wrapped(
|
||||||
|
&sealed(&rumor, SealForm::Encrypted, author),
|
||||||
|
kind,
|
||||||
|
at_ms / 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_seal_forms_round_trip() {
|
||||||
|
let author = Keys::generate();
|
||||||
|
let at_ms = 1_686_840_217_417;
|
||||||
|
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
|
||||||
|
|
||||||
|
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
|
||||||
|
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
|
||||||
|
|
||||||
|
let opened = open_wrap(&wrap, &group(0)).expect("opens");
|
||||||
|
assert_eq!(opened.author, author.public_key());
|
||||||
|
assert_eq!(opened.rumor.content, "Hey chat!");
|
||||||
|
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
|
||||||
|
assert_eq!(opened.wrapper_id, wrap.id);
|
||||||
|
assert_eq!(opened.at_ms, at_ms);
|
||||||
|
assert_eq!(opened.seal_form, SealForm::Encrypted);
|
||||||
|
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
|
||||||
|
|
||||||
|
// The wrap's `p` tag must identify neither the stream nor the author.
|
||||||
|
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
|
||||||
|
assert_ne!(p, group(0).pk_hex());
|
||||||
|
assert_ne!(p, author.public_key().to_hex());
|
||||||
|
|
||||||
|
// Ephemeral actions ride the same structure at a kind relays must drop.
|
||||||
|
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
|
||||||
|
assert_eq!(typing.kind.as_u16(), 21059);
|
||||||
|
assert_eq!(
|
||||||
|
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
|
||||||
|
"typing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The plaintext form carries the rumor's bytes verbatim, which is what
|
||||||
|
// lets a compaction re-wrap the signed edition into a later epoch.
|
||||||
|
let edition = build_rumor_secs(
|
||||||
|
3308,
|
||||||
|
author.public_key(),
|
||||||
|
"an edition",
|
||||||
|
vec![],
|
||||||
|
1_700_000_000,
|
||||||
|
);
|
||||||
|
let seal = sealed(&edition, SealForm::Plaintext, &author);
|
||||||
|
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
|
||||||
|
|
||||||
|
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
|
||||||
|
assert_eq!(opened.seal_form, SealForm::Plaintext);
|
||||||
|
|
||||||
|
let (rewrapped, _) =
|
||||||
|
rewrap_seal(&opened.seal, &group(1), &group(1), Timestamp::from_secs(2))
|
||||||
|
.expect("rewraps");
|
||||||
|
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
|
||||||
|
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
|
||||||
|
assert_eq!(reopened.author, author.public_key());
|
||||||
|
assert_eq!(
|
||||||
|
reopened.seal.sig, opened.seal.sig,
|
||||||
|
"the signature rides whole"
|
||||||
|
);
|
||||||
|
assert_ne!(reopened.wrapper_id, opened.wrapper_id);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
rewrap_seal(
|
||||||
|
&sealed(&edition, SealForm::Encrypted, &author),
|
||||||
|
&group(1),
|
||||||
|
&group(1),
|
||||||
|
Timestamp::from_secs(2)
|
||||||
|
),
|
||||||
|
Err(StreamError::NotRewrappable)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_wraps_are_dropped_in_order() {
|
||||||
|
let author = Keys::generate();
|
||||||
|
let impostor = Keys::generate();
|
||||||
|
|
||||||
|
// Kind and address are settled before any decryption is attempted.
|
||||||
|
let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
|
||||||
|
wrong_kind.kind = Kind::Custom(1058);
|
||||||
|
assert!(matches!(
|
||||||
|
open_wrap(&wrong_kind, &group(0)),
|
||||||
|
Err(StreamError::BadWrapKind(1058))
|
||||||
|
));
|
||||||
|
|
||||||
|
let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives");
|
||||||
|
let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
|
||||||
|
assert!(matches!(
|
||||||
|
open_wrap(&wrap, &foreign),
|
||||||
|
Err(StreamError::WrongStream)
|
||||||
|
));
|
||||||
|
|
||||||
|
// A flipped ciphertext byte fails the NIP-44 MAC.
|
||||||
|
let mut payload = BASE64
|
||||||
|
.decode(wrap.content.as_bytes())
|
||||||
|
.expect("content is base64");
|
||||||
|
payload[40] ^= 0x01;
|
||||||
|
let mut tampered = wrap.clone();
|
||||||
|
tampered.content = BASE64.encode(&payload);
|
||||||
|
assert!(matches!(
|
||||||
|
open_wrap(&tampered, &group(0)),
|
||||||
|
Err(StreamError::Decrypt(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
// A seal claiming an author it holds no signature for.
|
||||||
|
let seal = sealed(
|
||||||
|
&bound_rumor("spoof", author.public_key(), 1_000),
|
||||||
|
SealForm::Encrypted,
|
||||||
|
&impostor,
|
||||||
|
);
|
||||||
|
let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json");
|
||||||
|
swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
|
||||||
|
let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses");
|
||||||
|
assert!(matches!(
|
||||||
|
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
||||||
|
Err(StreamError::BadSealSignature)
|
||||||
|
));
|
||||||
|
|
||||||
|
// A seal that does not vouch for the rumor's author.
|
||||||
|
let seal = sealed(
|
||||||
|
&bound_rumor("spoof", impostor.public_key(), 1_000),
|
||||||
|
SealForm::Encrypted,
|
||||||
|
&author,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
||||||
|
Err(StreamError::AuthorMismatch)
|
||||||
|
));
|
||||||
|
|
||||||
|
// A claimed id the rumor's own bytes do not hash to. The plaintext seal
|
||||||
|
// smuggles the forgery through verbatim.
|
||||||
|
let rumor = bound_rumor("real", author.public_key(), 1_000);
|
||||||
|
let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json");
|
||||||
|
forged["id"] = serde_json::Value::String("00".repeat(32));
|
||||||
|
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string())
|
||||||
|
.custom_created_at(rumor.created_at)
|
||||||
|
.finalize(&author)
|
||||||
|
.expect("seals");
|
||||||
|
assert!(matches!(
|
||||||
|
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
|
||||||
|
Err(StreamError::BadRumorId)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Binding splices: another channel, another epoch, a duplicate or none.
|
||||||
|
let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat();
|
||||||
|
let rumor = bound_rumor("x", author.public_key(), 1_000);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)),
|
||||||
|
Err(StreamError::ChannelMismatch)
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
check_channel_binding(&rumor, &channel(), Epoch(1)),
|
||||||
|
Err(StreamError::EpochMismatch)
|
||||||
|
));
|
||||||
|
|
||||||
|
let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000);
|
||||||
|
assert!(matches!(
|
||||||
|
check_channel_binding(&duplicate, &channel(), Epoch(0)),
|
||||||
|
Err(StreamError::DuplicateTag(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000);
|
||||||
|
assert!(matches!(
|
||||||
|
check_channel_binding(&unbound, &channel(), Epoch(0)),
|
||||||
|
Err(StreamError::MissingTag(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let oversize = build_rumor_ms(
|
||||||
|
9,
|
||||||
|
author.public_key(),
|
||||||
|
&"x".repeat(NIP44_MAX_PLAINTEXT + 1),
|
||||||
|
vec![],
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
seal_content(&oversize, SealForm::Encrypted, &group(0)),
|
||||||
|
Err(StreamError::Oversize(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ms_is_a_drop_gate() {
|
||||||
|
let author = Keys::generate();
|
||||||
|
|
||||||
|
let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000);
|
||||||
|
assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000);
|
||||||
|
|
||||||
|
let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999);
|
||||||
|
assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999);
|
||||||
|
|
||||||
|
for malformed in ["1000", "007", "abc", "+5", ""] {
|
||||||
|
let rumor = build_rumor_secs(
|
||||||
|
9,
|
||||||
|
author.public_key(),
|
||||||
|
"x",
|
||||||
|
vec![Tag::custom(TAG_MS, [malformed.to_string()])],
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)),
|
||||||
|
"{malformed:?} must be malformed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present but valueless is malformed, not an offset-0 default.
|
||||||
|
let valueless = build_rumor_secs(
|
||||||
|
9,
|
||||||
|
author.public_key(),
|
||||||
|
"x",
|
||||||
|
vec![Tag::custom(TAG_MS, Vec::<String>::new())],
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_ms_strict(&valueless),
|
||||||
|
Err(StreamError::BadMs)
|
||||||
|
));
|
||||||
|
|
||||||
|
// A valued duplicate takes the first, matching Armada.
|
||||||
|
let repeated = build_rumor_secs(
|
||||||
|
9,
|
||||||
|
author.public_key(),
|
||||||
|
"x",
|
||||||
|
vec![
|
||||||
|
Tag::custom(TAG_MS, ["1".to_string()]),
|
||||||
|
Tag::custom(TAG_MS, ["2".to_string()]),
|
||||||
|
],
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
|||||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
|
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||||
|
|
||||||
macro_rules! setting_accessors {
|
macro_rules! setting_accessors {
|
||||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
@@ -138,7 +141,7 @@ impl Default for Settings {
|
|||||||
screening: true,
|
screening: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
trusted_relays: vec![],
|
trusted_relays: vec![],
|
||||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +220,12 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let settings = task.await.unwrap_or(Settings::default());
|
let mut settings = task.await.unwrap_or(Settings::default());
|
||||||
|
|
||||||
|
// Move settings still pointed at the old default file server over to the new one
|
||||||
|
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
|
||||||
|
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// Update settings
|
// Update settings
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
|
||||||
|
aes-gcm.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
|
|||||||
+32
-10
@@ -4,30 +4,52 @@ use anyhow::{Error, anyhow};
|
|||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
use crate::file::sha256_hex;
|
||||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
|
||||||
let data = smol::fs::read(path).await?;
|
|
||||||
let keys = Keys::generate();
|
|
||||||
|
|
||||||
// Construct the blossom client
|
/// Upload a blob to a blossom server and return its URL
|
||||||
let client = BlossomClient::new(server);
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub(crate) async fn upload_blob(
|
||||||
|
server: &Url,
|
||||||
|
data: Vec<u8>,
|
||||||
|
content_type: &str,
|
||||||
|
sha256: &str,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<Url, Error> {
|
||||||
|
let client = BlossomClient::new(server.clone());
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let content_type = content_type.to_string();
|
||||||
|
let base = server.clone();
|
||||||
|
let hash = sha256.to_string();
|
||||||
|
|
||||||
Tokio::spawn(cx, async move {
|
Tokio::spawn(cx, async move {
|
||||||
let blob = client
|
match client
|
||||||
.upload_blob(data, Some(content_type), None, Some(&keys))
|
.upload_blob(data, Some(content_type), None, Some(&keys))
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
Ok(blob.url)
|
Ok(blob) => Ok(blob.url),
|
||||||
|
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
|
||||||
|
Err(e) => Err(anyhow!(e.to_string())),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
|
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||||
|
let data = smol::fs::read(&path).await?;
|
||||||
|
let sha256 = sha256_hex(&data);
|
||||||
|
|
||||||
|
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
Err(anyhow!("File upload not supported on web"))
|
Err(anyhow!("File upload not supported on web"))
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
|
|||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
/// Default image cache size
|
|
||||||
pub const IMAGE_CACHE_SIZE: usize = 20;
|
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use aes_gcm::aead::consts::{U12, U16, U32};
|
||||||
|
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||||
|
use aes_gcm::aes::Aes256;
|
||||||
|
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
||||||
|
use anyhow::{Error, anyhow, bail};
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use futures::AsyncReadExt;
|
||||||
|
use gpui::http_client::AsyncBody;
|
||||||
|
use gpui::{AsyncApp, SharedString};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use mime_guess::from_path;
|
||||||
|
use nostr::nips::nip94::Sha256Hash;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub const ALGORITHM: &str = "aes-gcm";
|
||||||
|
|
||||||
|
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
const TAG_SHA256: &str = "x";
|
||||||
|
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||||
|
const TAG_FILE_TYPE: &str = "file-type";
|
||||||
|
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||||
|
const TAG_KEY: &str = "decryption-key";
|
||||||
|
const TAG_NONCE: &str = "decryption-nonce";
|
||||||
|
const TAG_SIZE: &str = "size";
|
||||||
|
const TAG_DIM: &str = "dim";
|
||||||
|
const TAG_ALT: &str = "alt";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EncryptedFile {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub key: String,
|
||||||
|
pub nonce: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FileAttachment {
|
||||||
|
pub url: Url,
|
||||||
|
pub mime: String,
|
||||||
|
pub key: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
pub original_sha256: Option<String>,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub dim: Option<(u32, u32)>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileAttachment {
|
||||||
|
pub fn tags(&self) -> Vec<Tag> {
|
||||||
|
let mut tags = vec![
|
||||||
|
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
||||||
|
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
||||||
|
Tag::custom(TAG_KEY, [self.key.clone()]),
|
||||||
|
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(sha256) = &self.sha256 {
|
||||||
|
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(original_sha256) = &self.original_sha256 {
|
||||||
|
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(size) = self.size {
|
||||||
|
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((width, height)) = self.dim {
|
||||||
|
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
||||||
|
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
url: Url::parse(content).ok()?,
|
||||||
|
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
||||||
|
key: tag_value(tags, TAG_KEY)?.to_string(),
|
||||||
|
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
||||||
|
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
||||||
|
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
||||||
|
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
||||||
|
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
||||||
|
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_image(&self) -> bool {
|
||||||
|
self.mime.starts_with("image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> SharedString {
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
return name.clone().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.size {
|
||||||
|
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
||||||
|
None => self.mime.clone().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||||
|
let key = Aes256Gcm::generate_key(OsRng);
|
||||||
|
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||||
|
let cipher = AesGcm::<Aes256, U16>::new(&key);
|
||||||
|
|
||||||
|
let data = cipher
|
||||||
|
.encrypt(&nonce, data)
|
||||||
|
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
||||||
|
|
||||||
|
Ok(EncryptedFile {
|
||||||
|
data,
|
||||||
|
key: HEXLOWER.encode(key.as_slice()),
|
||||||
|
nonce: HEXLOWER.encode(nonce.as_slice()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||||
|
let key = decode(key, "decryption key")?;
|
||||||
|
let nonce = decode(nonce, "decryption nonce")?;
|
||||||
|
|
||||||
|
if key.len() != 32 {
|
||||||
|
bail!(
|
||||||
|
"Invalid decryption key length: expected 32 bytes, got {}",
|
||||||
|
key.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match nonce.len() {
|
||||||
|
12 => Aes256Gcm::new_from_slice(&key)
|
||||||
|
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||||
|
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
||||||
|
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||||
|
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
|
||||||
|
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||||
|
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
|
||||||
|
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||||
|
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
|
||||||
|
.map_err(|_| anyhow!("Invalid decryption key"))?
|
||||||
|
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
|
||||||
|
.map_err(|_| anyhow!("Failed to decrypt file")),
|
||||||
|
len => bail!("Unsupported decryption nonce length: {len} bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||||
|
|
||||||
|
Sha256Hash::from_byte_array(hash).to_hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn upload_encrypted(
|
||||||
|
server: Url,
|
||||||
|
path: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<FileAttachment, Error> {
|
||||||
|
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned());
|
||||||
|
let data = smol::fs::read(&path).await?;
|
||||||
|
|
||||||
|
let encrypted = encrypt(&data)?;
|
||||||
|
let sha256 = sha256_hex(&encrypted.data);
|
||||||
|
let original_sha256 = sha256_hex(&data);
|
||||||
|
let size = encrypted.data.len() as u64;
|
||||||
|
let base_url = server.to_string();
|
||||||
|
|
||||||
|
let url = crate::blossom::upload_blob(
|
||||||
|
&server,
|
||||||
|
encrypted.data,
|
||||||
|
"application/octet-stream",
|
||||||
|
&sha256,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let message = e.to_string();
|
||||||
|
|
||||||
|
if !message.contains("415") {
|
||||||
|
return anyhow!(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow!(
|
||||||
|
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
|
||||||
|
opaque data, which this file server does not accept. Choose a different file
|
||||||
|
server in the settings."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(FileAttachment {
|
||||||
|
url,
|
||||||
|
mime,
|
||||||
|
key: encrypted.key,
|
||||||
|
nonce: encrypted.nonce,
|
||||||
|
sha256: Some(sha256),
|
||||||
|
original_sha256: Some(original_sha256),
|
||||||
|
size: Some(size),
|
||||||
|
dim: None,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn upload_encrypted(
|
||||||
|
_server: Url,
|
||||||
|
_path: PathBuf,
|
||||||
|
_cx: &AsyncApp,
|
||||||
|
) -> Result<FileAttachment, Error> {
|
||||||
|
Err(anyhow!("File upload not supported on web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_and_decrypt(
|
||||||
|
url: &Url,
|
||||||
|
key: &str,
|
||||||
|
nonce: &str,
|
||||||
|
expected_sha256: Option<&str>,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<Vec<u8>, Error> {
|
||||||
|
let client = cx.update(|app| app.http_client());
|
||||||
|
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
bail!("Failed to download file: HTTP {}", response.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
response
|
||||||
|
.into_body()
|
||||||
|
.take(MAX_FILE_SIZE as u64 + 1)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if data.len() > MAX_FILE_SIZE {
|
||||||
|
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected) = expected_sha256
|
||||||
|
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||||
|
{
|
||||||
|
bail!("File hash mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(&data, key, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt a file attachment into a temporary file.
|
||||||
|
///
|
||||||
|
/// The same attachment always maps to the same path, so callers can render the
|
||||||
|
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn download_and_decrypt_to_file(
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<PathBuf, Error> {
|
||||||
|
let name = file
|
||||||
|
.sha256
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||||
|
|
||||||
|
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||||
|
.and_then(|extensions| extensions.first())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("bin");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join("coop-files")
|
||||||
|
.join(format!("{name}.{extension}"));
|
||||||
|
|
||||||
|
if smol::fs::metadata(&path).await.is_ok() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = download_and_decrypt(
|
||||||
|
&file.url,
|
||||||
|
&file.key,
|
||||||
|
&file.nonce,
|
||||||
|
file.sha256.as_deref(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(parent) = path.parent() else {
|
||||||
|
bail!("Invalid file path");
|
||||||
|
};
|
||||||
|
smol::fs::create_dir_all(parent).await?;
|
||||||
|
|
||||||
|
// Write under a temporary name first, so an interrupted download is never reused
|
||||||
|
let partial = path.with_extension("download");
|
||||||
|
smol::fs::write(&partial, data).await?;
|
||||||
|
smol::fs::rename(&partial, &path).await?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn download_and_decrypt_to_file(
|
||||||
|
_file: &FileAttachment,
|
||||||
|
_cx: &AsyncApp,
|
||||||
|
) -> Result<PathBuf, Error> {
|
||||||
|
Err(anyhow!("File download not supported on web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
|
tags.iter()
|
||||||
|
.find(|tag| tag.kind() == name)
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||||
|
let (width, height) = value.split_once('x')?;
|
||||||
|
|
||||||
|
Some((width.parse().ok()?, height.parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
|
||||||
|
HEXLOWER
|
||||||
|
.decode(value.to_ascii_lowercase().as_bytes())
|
||||||
|
.map_err(|_| anyhow!("Invalid {label} encoding"))
|
||||||
|
}
|
||||||
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
|
|||||||
|
|
||||||
mod blossom;
|
mod blossom;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
mod file;
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod nip4e;
|
mod nip4e;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use blossom::*;
|
pub use blossom::*;
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
|
pub use file::*;
|
||||||
pub use nip4e::*;
|
pub use nip4e::*;
|
||||||
pub use nip05::*;
|
pub use nip05::*;
|
||||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
|
|||||||
id: "coop".into(),
|
id: "coop".into(),
|
||||||
name: "Coop Default Theme".into(),
|
name: "Coop Default Theme".into(),
|
||||||
author: "Coop".into(),
|
author: "Coop".into(),
|
||||||
url: "https://github.com/lumehq/coop".into(),
|
url: "https://github.com/reyakov/coop".into(),
|
||||||
light: ThemeColors::light(),
|
light: ThemeColors::light(),
|
||||||
dark: ThemeColors::dark(),
|
dark: ThemeColors::dark(),
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ mod tests {
|
|||||||
"id": "test-theme",
|
"id": "test-theme",
|
||||||
"name": "Test Theme",
|
"name": "Test Theme",
|
||||||
"author": "Coop",
|
"author": "Coop",
|
||||||
"url": "https://github.com/lumehq/coop",
|
"url": "https://github.com/reyakov/coop",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#ffffff",
|
"background": "#ffffff",
|
||||||
"surface_background": "#fafafa",
|
"surface_background": "#fafafa",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub enum IconName {
|
|||||||
InboxFill,
|
InboxFill,
|
||||||
Link,
|
Link,
|
||||||
Loader,
|
Loader,
|
||||||
|
Lock,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
|
|||||||
Self::InboxFill => "icons/inbox-fill.svg",
|
Self::InboxFill => "icons/inbox-fill.svg",
|
||||||
Self::Link => "icons/link.svg",
|
Self::Link => "icons/link.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
|
Self::Lock => "icons/lock.svg",
|
||||||
Self::Moon => "icons/moon.svg",
|
Self::Moon => "icons/moon.svg",
|
||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
Self::PlusCircle => "icons/plus-circle.svg",
|
Self::PlusCircle => "icons/plus-circle.svg",
|
||||||
|
|||||||
+37
-26
@@ -4,18 +4,18 @@ use ::settings::AppSettings;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::{CoopImageCache, download_dir};
|
use common::download_dir;
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px,
|
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{PersonRegistry, shorten_pubkey};
|
use person::{PersonRegistry, shorten_pubkey};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -64,9 +64,6 @@ pub struct Workspace {
|
|||||||
/// App's Dock Area
|
/// App's Dock Area
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
|
|
||||||
/// App's Image Cache
|
|
||||||
image_cache: Entity<CoopImageCache>,
|
|
||||||
|
|
||||||
/// Async tasks
|
/// Async tasks
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
@@ -82,7 +79,6 @@ impl Workspace {
|
|||||||
|
|
||||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||||
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -233,7 +229,6 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
sidebar,
|
sidebar,
|
||||||
dock,
|
dock,
|
||||||
image_cache,
|
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -380,12 +375,9 @@ impl Workspace {
|
|||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
Command::Update => {
|
||||||
let auto_updater = AutoUpdater::global(cx);
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
auto_updater.update(cx, |this, cx| {
|
auto_updater.update(cx, |this, cx| this.check(cx));
|
||||||
this.updater.update(cx, |updater, cx| {
|
}
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,7 +551,7 @@ impl Workspace {
|
|||||||
.caret()
|
.caret()
|
||||||
.compact()
|
.compact()
|
||||||
.transparent()
|
.transparent()
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, cx| {
|
||||||
let avatar = avatar.clone();
|
let avatar = avatar.clone();
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
|
|
||||||
@@ -593,12 +585,15 @@ impl Workspace {
|
|||||||
IconName::Sun,
|
IconName::Sun,
|
||||||
Box::new(Command::ToggleTheme),
|
Box::new(Command::ToggleTheme),
|
||||||
)
|
)
|
||||||
.separator()
|
// Only offer in-app updates when auto-update is
|
||||||
.menu_with_icon(
|
// enabled (managed channels update themselves).
|
||||||
|
.when(AutoUpdater::is_available(cx), |this| {
|
||||||
|
this.separator().menu_with_icon(
|
||||||
"Check for Updates",
|
"Check for Updates",
|
||||||
IconName::Device,
|
IconName::Device,
|
||||||
Box::new(Command::Update),
|
Box::new(Command::Update),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
"Settings",
|
"Settings",
|
||||||
IconName::Settings,
|
IconName::Settings,
|
||||||
@@ -610,7 +605,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let updater = AutoUpdater::global(cx);
|
let auto_updater = AutoUpdater::try_global(cx);
|
||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -622,15 +617,36 @@ impl Workspace {
|
|||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
let announcement = profile.announcement();
|
let announcement = profile.announcement();
|
||||||
let updater_idle = updater.read(cx).idle(cx);
|
|
||||||
|
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
||||||
|
let updater = updater.read(cx);
|
||||||
|
(!updater.idle()).then(|| updater.status())
|
||||||
|
});
|
||||||
|
|
||||||
|
let staged_update = auto_updater
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|updater| updater.read(cx).staged());
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when(!updater_idle, |this| {
|
.when_some(updater_status, |this, status| {
|
||||||
let status = updater.read(cx).status(cx);
|
|
||||||
this.child(div().text_xs().italic().child(status))
|
this.child(div().text_xs().italic().child(status))
|
||||||
})
|
})
|
||||||
|
.when(staged_update, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("restart-to-update")
|
||||||
|
.label("Restart to Update")
|
||||||
|
.tooltip("Quit and relaunch into the installed update")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click(cx.listener(|_this, _event, _window, cx| {
|
||||||
|
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||||
|
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
.when(nip4e_enabled, |this| {
|
.when(nip4e_enabled, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
@@ -764,10 +780,6 @@ impl Render for Workspace {
|
|||||||
div()
|
div()
|
||||||
.id("workspace")
|
.id("workspace")
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
image_cache(self.image_cache.clone())
|
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
@@ -792,7 +804,6 @@ impl Render for Workspace {
|
|||||||
)
|
)
|
||||||
.child(self.dock.clone()),
|
.child(self.dock.clone()),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
// Notifications
|
// Notifications
|
||||||
.children(notification_layer)
|
.children(notification_layer)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
Task, TextAlign, Window, div, rems,
|
Task, TextAlign, Window, div, rems, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -297,6 +297,7 @@ impl Focusable for ContactListPanel {
|
|||||||
impl Render for ContactListPanel {
|
impl Render for ContactListPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("contact-list-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::Error;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
Window, div,
|
Window, div, retain_all,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -167,13 +167,15 @@ impl ProfilePanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
// Selecting no file means the prompt was cancelled
|
||||||
|
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_uploading(true, cx);
|
this.set_uploading(true, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut paths = path.await??.context("Not found")?;
|
|
||||||
let path = paths.pop().context("No path")?;
|
|
||||||
|
|
||||||
// Upload via blossom client
|
// Upload via blossom client
|
||||||
match upload(server, path, cx).await {
|
match upload(server, path, cx).await {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
@@ -319,6 +321,7 @@ impl Render for ProfilePanel {
|
|||||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.image_cache(retain_all("profile-panel"))
|
||||||
.p_3()
|
.p_3()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ use std::ops::Range;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt, coop_cache};
|
use common::{DebouncedDelay, TimestampExt};
|
||||||
use entry::RoomEntry;
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||||
Window, div, uniform_list,
|
Window, div, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
use state::{FIND_DELAY, NostrRegistry};
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
@@ -521,7 +521,7 @@ impl Render for Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "1.0.0"
|
version = "1.0.2"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
@@ -35,13 +35,13 @@
|
|||||||
<content_attribute id="social-audio">intense</content_attribute>
|
<content_attribute id="social-audio">intense</content_attribute>
|
||||||
</content_rating>
|
</content_rating>
|
||||||
|
|
||||||
<url type="homepage">https://reya.su/coop</url>
|
<url type="homepage">https://coopchat.xyz</url>
|
||||||
<url type="bugtracker">https://github.com/lumehq/coop/issues</url>
|
<url type="bugtracker">https://github.com/reyakov/coop/issues</url>
|
||||||
<url type="faq">https://github.com/lumehq/coop</url>
|
<url type="faq">https://github.com/reyakov/coop</url>
|
||||||
<url type="help">https://github.com/lumehq/coop/issues</url>
|
<url type="help">https://github.com/reyakov/coop/issues</url>
|
||||||
<url type="contact">https://reya.su/</url>
|
<url type="contact">reyakov@proton.me</url>
|
||||||
<url type="vcs-browser">https://github.com/lumehq/coop</url>
|
<url type="vcs-browser">https://github.com/reykov/coop</url>
|
||||||
<url type="contribute">https://github.com/lumehq/coop/blob/main/CONTRIBUTING.md</url>
|
<url type="contribute">https://github.com/reyakov/coop/blob/main/CONTRIBUTING.md</url>
|
||||||
|
|
||||||
<supports>
|
<supports>
|
||||||
<internet>yes</internet>
|
<internet>yes</internet>
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# Snaps built by snapcraft without Snap Store credentials are unsigned and
|
||||||
|
# cannot be installed without bypassing signature checks. Use:
|
||||||
|
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
|
||||||
|
# For signed installs (`snap install coop`), publish via the Snap Store.
|
||||||
name: coop
|
name: coop
|
||||||
title: Coop
|
title: Coop
|
||||||
base: core24
|
base: core24
|
||||||
@@ -10,10 +14,10 @@ description: |
|
|||||||
grade: stable
|
grade: stable
|
||||||
confinement: classic
|
confinement: classic
|
||||||
compression: lzo
|
compression: lzo
|
||||||
website: https://reya.su/coop
|
website: https://reya.info/coop
|
||||||
source-code: https://github.com/lumehq/coop
|
source-code: https://git.reya.info/reya/coop
|
||||||
issues: https://github.com/lumehq/coop/issues
|
issues: https://github.com/reyakov/coop/issues
|
||||||
contact: https://reya.su
|
contact: https://coopchat.xyz
|
||||||
|
|
||||||
parts:
|
parts:
|
||||||
coop:
|
coop:
|
||||||
|
|||||||
@@ -35,3 +35,7 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
|
|||||||
snapcraft --destructive-mode --output "$SNAP_NAME"
|
snapcraft --destructive-mode --output "$SNAP_NAME"
|
||||||
|
|
||||||
echo "Created snap package: $SNAP_NAME"
|
echo "Created snap package: $SNAP_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "This snap is unsigned (built without Snap Store credentials)."
|
||||||
|
echo "To install it locally, use:"
|
||||||
|
echo " sudo snap install --dangerous ./$SNAP_NAME"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ cd "$(dirname "$0")/.."
|
|||||||
# Configuration
|
# Configuration
|
||||||
APP_ID="su.reya.coop"
|
APP_ID="su.reya.coop"
|
||||||
APP_NAME="Coop"
|
APP_NAME="Coop"
|
||||||
REPO_URL="https://git.reya.su/reya/coop"
|
REPO_URL="https://git.reya.info/reya/coop"
|
||||||
BRANDING_LIGHT="#FFE629"
|
BRANDING_LIGHT="#FFE629"
|
||||||
BRANDING_DARK="#FFE629"
|
BRANDING_DARK="#FFE629"
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ modules:
|
|||||||
sources:
|
sources:
|
||||||
# Main source code - specific commit
|
# Main source code - specific commit
|
||||||
- type: git
|
- type: git
|
||||||
url: https://git.reya.su/reya/coop.git
|
url: https://git.reya.info/reya/coop.git
|
||||||
commit: "@COMMIT@"
|
commit: "@COMMIT@"
|
||||||
tag: "v@VERSION@"
|
tag: "v@VERSION@"
|
||||||
|
|
||||||
|
|||||||
+31
-13
@@ -29,23 +29,29 @@ fi
|
|||||||
# Function to update version in a Cargo.toml file
|
# Function to update version in a Cargo.toml file
|
||||||
update_version() {
|
update_version() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
local backup="${file}.bak"
|
local tmp="${file}.tmp"
|
||||||
|
|
||||||
# Backup the original file
|
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
|
||||||
cp "$file" "$backup"
|
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
|
||||||
|
# suffix instead of the extended-regex flag, leaving a stray
|
||||||
# More flexible regex that handles various version formats and whitespace
|
# `Cargo.toml-E` behind and never updating the file.
|
||||||
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
|
# Writing to a temp file and moving it over works on both implementations.
|
||||||
|
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
|
||||||
|
&& mv "$tmp" "$file"; then
|
||||||
echo "✓ Updated version to $NEW_VERSION in $file"
|
echo "✓ Updated version to $NEW_VERSION in $file"
|
||||||
else
|
else
|
||||||
echo "Error: Failed to update version in $file"
|
echo "Error: Failed to update version in $file"
|
||||||
# Restore original backup
|
# Remove any partial temp file; the original file is untouched
|
||||||
mv "$backup" "$file"
|
rm -f "$tmp"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove the backup file
|
# The substitution can silently match nothing (e.g. a `version.workspace` key),
|
||||||
rm -f "$backup"
|
# so verify the new version actually landed before moving on.
|
||||||
|
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
|
||||||
|
echo "Error: Version line not found/updated in $file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update both Cargo.toml files
|
# Update both Cargo.toml files
|
||||||
@@ -53,9 +59,17 @@ echo "Updating versions..."
|
|||||||
update_version "$WORKSPACE_CARGO"
|
update_version "$WORKSPACE_CARGO"
|
||||||
update_version "$CRATE_CARGO"
|
update_version "$CRATE_CARGO"
|
||||||
|
|
||||||
|
COMMIT_MSG="chore: release version $NEW_VERSION"
|
||||||
|
|
||||||
|
# When the requested version is already set there is nothing to bump or commit,
|
||||||
|
# so the current commit is tagged as-is.
|
||||||
|
if git diff --quiet -- "$WORKSPACE_CARGO" "$CRATE_CARGO"; then
|
||||||
|
echo "Version is already $NEW_VERSION, tagging the current commit"
|
||||||
|
else
|
||||||
# Check git status before committing
|
# Check git status before committing
|
||||||
echo "Checking git status..."
|
echo "Checking git status..."
|
||||||
if git status --porcelain | grep -q .; then
|
# The version files are always modified at this point, so only ask about other changes.
|
||||||
|
if [ -n "$(git status --porcelain -- . ":(exclude,top)$WORKSPACE_CARGO" ":(exclude,top)$CRATE_CARGO")" ]; then
|
||||||
echo "Current uncommitted changes:"
|
echo "Current uncommitted changes:"
|
||||||
git status --short
|
git status --short
|
||||||
|
|
||||||
@@ -92,8 +106,6 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Commit the changes
|
# Commit the changes
|
||||||
COMMIT_MSG="chore: release version $NEW_VERSION"
|
|
||||||
|
|
||||||
if git commit -m "$COMMIT_MSG"; then
|
if git commit -m "$COMMIT_MSG"; then
|
||||||
echo "✓ Committed version changes"
|
echo "✓ Committed version changes"
|
||||||
else
|
else
|
||||||
@@ -109,10 +121,16 @@ else
|
|||||||
echo "Error: Failed to push version changes to origin"
|
echo "Error: Failed to push version changes to origin"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Create git tag
|
# Create git tag
|
||||||
TAG_NAME="v$NEW_VERSION"
|
TAG_NAME="v$NEW_VERSION"
|
||||||
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||||
|
echo "Error: tag $TAG_NAME already exists"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
|
||||||
echo "✓ Created git tag: $TAG_NAME"
|
echo "✓ Created git tag: $TAG_NAME"
|
||||||
else
|
else
|
||||||
|
|||||||
+16
-1
@@ -15,11 +15,26 @@ if [ "$#" -ne 1 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Get system architecture (same mapping as script/bundle-snap)
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) ARCH_SUFFIX="x86_64" ;;
|
||||||
|
aarch64) ARCH_SUFFIX="aarch64" ;;
|
||||||
|
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
|
||||||
|
if [ ! -f "$snap_file" ]; then
|
||||||
|
echo "Snap file not found: $snap_file"
|
||||||
|
echo "Build it first with: script/bundle-snap $1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Rerun as root
|
# Rerun as root
|
||||||
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
||||||
|
|
||||||
snap remove coop || true
|
snap remove coop || true
|
||||||
mkdir -p snap
|
mkdir -p snap
|
||||||
rm -rf snap/unpacked
|
rm -rf snap/unpacked
|
||||||
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
|
unsquashfs -dest snap/unpacked "$snap_file"
|
||||||
snap try --classic snap/unpacked
|
snap try --classic snap/unpacked
|
||||||
|
|||||||
Reference in New Issue
Block a user