mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-07-30 08:46:04 -04:00
Add public shares design docs and remote-access server improvements
- Add SHARE-000..011 task specs and docs/design/shares.md defining the sd.app public sharing architecture and wire protocols - Rework INDEX-010 to scope ephemeral UUID reconciliation per-library instead of a single global map, since core can have multiple libraries loaded at once - Add sidecar serving and configurable bind host to apps/server, and wire the web platform's daemon status to the server's own origin - Add quick preview button to the file inspector, with a null-safe useOptionalExplorer for use outside the explorer context
This commit is contained in:
@@ -6,7 +6,7 @@ assignee: jamiepine
|
||||
parent: INDEX-000
|
||||
priority: Critical
|
||||
tags: [indexing, ephemeral, persistent, uuid, foundation]
|
||||
last_updated: 2026-02-07
|
||||
last_updated: 2026-05-10
|
||||
related_tasks: [INDEX-001, FSYNC-003, FILE-006]
|
||||
---
|
||||
|
||||
@@ -16,6 +16,8 @@ The ephemeral and persistent indexes currently share UUIDs in one direction only
|
||||
|
||||
This task makes the ephemeral index a true superset layer on top of the persistent index by reusing persistent UUIDs when they exist. This is the foundational primitive for file sync, smart copy, and path intersection operations.
|
||||
|
||||
The core can have multiple libraries loaded at the same time and does not have a global "active library". Reconciliation must therefore be library-scoped: the filesystem structure can stay shared in the global ephemeral index, but UUID identity must be resolved per library.
|
||||
|
||||
## Problem
|
||||
|
||||
- Volume indexing an already-persistent location generates new UUIDs, duplicating identity
|
||||
@@ -35,9 +37,10 @@ Ephemeral Browse → Assign v4 UUIDs → [promote] → Persistent stores same UU
|
||||
### Target Flow (Bidirectional)
|
||||
|
||||
```
|
||||
Ephemeral Browse → Assign v4 UUIDs → [reconcile] → Check persistent index
|
||||
├── Match found → adopt persistent UUID
|
||||
└── No match → keep v4 UUID
|
||||
Library-scoped ephemeral browse → Assign temporary UUIDs for that library
|
||||
→ [reconcile] → Check that library's persistent index
|
||||
├── Match found → adopt persistent UUID
|
||||
└── No match → keep library-local v4 UUID
|
||||
```
|
||||
|
||||
### Design Constraints
|
||||
@@ -45,34 +48,80 @@ Ephemeral Browse → Assign v4 UUIDs → [reconcile] → Check persistent index
|
||||
1. **Do not slow down ephemeral discovery.** The ephemeral indexer must remain fast (~50K files/sec). No database queries during the filesystem walk.
|
||||
2. **Reconciliation is a separate pass.** After ephemeral discovery completes, run a background reconciliation against the persistent index for overlapping paths.
|
||||
3. **Lazy resolution as fallback.** If reconciliation hasn't run yet, UUID lookups can check the persistent index on demand.
|
||||
4. **Single EphemeralIndex instance.** The global `EphemeralIndexCache` holds one shared index. Reconciliation updates UUIDs in place.
|
||||
4. **Single shared filesystem index.** The global `EphemeralIndexCache` should keep one shared path and metadata structure for memory efficiency.
|
||||
5. **Library-scoped UUID overlay.** Reconciliation must not overwrite one global UUID per path. UUIDs are scoped by `(library_id, entry_id)` so two loaded libraries can map the same physical path to different persistent entry UUIDs.
|
||||
|
||||
### Library Scoping
|
||||
|
||||
The ephemeral cache is process-local and shared across all loaded libraries. Persistent databases are per-library. The same absolute path may exist in more than one loaded library, and each library can have different entry UUIDs, tags, metadata, sync state, and permissions.
|
||||
|
||||
The correct model is:
|
||||
|
||||
```text
|
||||
Shared ephemeral structure:
|
||||
path -> EntryId
|
||||
EntryId -> metadata
|
||||
EntryId -> content kind
|
||||
|
||||
Library identity overlay:
|
||||
library_id -> EntryId -> UUID
|
||||
```
|
||||
|
||||
This lets expensive filesystem discovery stay shared while keeping persistent identity correct for each loaded library.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add Persistent UUID Lookup to EphemeralIndex
|
||||
### 1. Add Library-Scoped UUID Storage to EphemeralIndex
|
||||
|
||||
Add a method that accepts pre-resolved UUIDs from an external source (the persistent DB) and patches them into the ephemeral index's `entry_uuids` map.
|
||||
Replace the single global `entry_uuids: HashMap<EntryId, Uuid>` with a library-scoped overlay. Existing call sites should pass the library ID when reading or assigning UUIDs.
|
||||
|
||||
```rust
|
||||
// core/src/ops/indexing/ephemeral/index.rs
|
||||
|
||||
pub type LibraryId = Uuid;
|
||||
|
||||
pub struct EphemeralIndex {
|
||||
// path and metadata fields stay shared
|
||||
entry_uuids_by_library: HashMap<LibraryId, HashMap<EntryId, Uuid>>,
|
||||
}
|
||||
|
||||
impl EphemeralIndex {
|
||||
/// Reconcile ephemeral UUIDs with persistent entries.
|
||||
/// For each path in the provided map, if a matching ephemeral entry exists,
|
||||
/// replace its UUID with the persistent one.
|
||||
/// Returns count of UUIDs reconciled.
|
||||
pub fn get_entry_uuid(&self, library_id: Uuid, path: &Path) -> Option<Uuid> {
|
||||
let entry_id = self.path_index.get(path)?;
|
||||
self.entry_uuids_by_library
|
||||
.get(&library_id)?
|
||||
.get(entry_id)
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn get_or_assign_uuid(&mut self, library_id: Uuid, path: &Path) -> Uuid {
|
||||
let Some(&entry_id) = self.path_index.get(path) else {
|
||||
return Uuid::new_v4();
|
||||
};
|
||||
|
||||
let uuids = self.entry_uuids_by_library.entry(library_id).or_default();
|
||||
*uuids.entry(entry_id).or_insert_with(Uuid::new_v4)
|
||||
}
|
||||
|
||||
pub fn reconcile_persistent_uuids(
|
||||
&mut self,
|
||||
library_id: Uuid,
|
||||
persistent_uuids: &HashMap<PathBuf, Uuid>,
|
||||
) -> usize {
|
||||
) -> Vec<(PathBuf, Uuid)> {
|
||||
let uuids = self.entry_uuids_by_library.entry(library_id).or_default();
|
||||
let mut changed = Vec::new();
|
||||
|
||||
let mut count = 0;
|
||||
for (path, persistent_uuid) in persistent_uuids {
|
||||
if let Some(&entry_id) = self.path_index.get(path) {
|
||||
self.entry_uuids.insert(entry_id, *persistent_uuid);
|
||||
count += 1;
|
||||
let existing = uuids.insert(entry_id, *persistent_uuid);
|
||||
if existing != Some(*persistent_uuid) {
|
||||
changed.push((path.clone(), *persistent_uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
|
||||
changed
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -124,49 +173,28 @@ pub async fn extract_persistent_uuids_for_path(
|
||||
|
||||
For large persistent locations this query could return thousands of entries. Batch the path resolution and use the `directory_paths` cache (O(1) per directory) to keep it fast.
|
||||
|
||||
### 3. Reconciliation Pass on EphemeralIndexCache
|
||||
### 3. Library-Scoped Reconciliation Pass on EphemeralIndexCache
|
||||
|
||||
After ephemeral discovery completes for a path, check if any persistent locations overlap with the scanned path and run reconciliation.
|
||||
After ephemeral discovery completes for a path, reconcile against the library that requested the operation. Do not scan all loaded libraries and overwrite global UUIDs.
|
||||
|
||||
```rust
|
||||
// core/src/ops/indexing/ephemeral/cache.rs
|
||||
|
||||
impl EphemeralIndexCache {
|
||||
/// Run after ephemeral indexing completes for a path.
|
||||
/// Checks all libraries for persistent locations that overlap with the
|
||||
/// ephemeral path and reconciles UUIDs.
|
||||
pub async fn reconcile_with_persistent(
|
||||
&self,
|
||||
library_id: Uuid,
|
||||
scanned_path: &Path,
|
||||
libraries: &LibraryManager,
|
||||
) -> usize {
|
||||
let mut total = 0;
|
||||
db: &DatabaseConnection,
|
||||
) -> Result<Vec<(PathBuf, Uuid)>> {
|
||||
let persistent_uuids = extract_persistent_uuids_for_path(db, scanned_path).await?;
|
||||
|
||||
for library in libraries.list().await {
|
||||
let db = library.db();
|
||||
match extract_persistent_uuids_for_path(db, scanned_path).await {
|
||||
Ok(persistent_uuids) if !persistent_uuids.is_empty() => {
|
||||
let mut index = self.index.write().await;
|
||||
total += index.reconcile_persistent_uuids(&persistent_uuids);
|
||||
}
|
||||
Ok(_) => {} // No overlap with this library
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to reconcile UUIDs for library {}: {}",
|
||||
library.id(), e
|
||||
);
|
||||
}
|
||||
}
|
||||
if persistent_uuids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
tracing::info!(
|
||||
"Reconciled {} ephemeral UUIDs with persistent index for {}",
|
||||
total, scanned_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
total
|
||||
let mut index = self.index.write().await;
|
||||
Ok(index.reconcile_persistent_uuids(library_id, &persistent_uuids))
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -180,18 +208,19 @@ Wire reconciliation into the ephemeral indexing job completion path. The indexer
|
||||
|
||||
cache.mark_indexing_complete(&path);
|
||||
|
||||
// Reconcile with persistent index in background
|
||||
// Reconcile with this library's persistent index in the background.
|
||||
let cache_clone = cache.clone();
|
||||
let libraries = ctx.library().core_context().libraries().await;
|
||||
let library_id = ctx.library().id();
|
||||
let db = ctx.library().db().clone();
|
||||
let path_clone = path.clone();
|
||||
tokio::spawn(async move {
|
||||
cache_clone
|
||||
.reconcile_with_persistent(&path_clone, &libraries)
|
||||
let _ = cache_clone
|
||||
.reconcile_with_persistent(library_id, &path_clone, db.conn())
|
||||
.await;
|
||||
});
|
||||
```
|
||||
|
||||
Spawning as a background task keeps the indexing job fast. The UI shows ephemeral UUIDs immediately, then silently corrects them when reconciliation completes. Since the ephemeral index is the browsing layer, UUID changes propagate to the UI via the existing `ResourceChanged` event system.
|
||||
Spawning as a background task keeps the indexing job fast. The UI shows library-local ephemeral UUIDs immediately, then corrects them when reconciliation completes. Since UUIDs are library-scoped, another loaded library viewing the same path is unaffected.
|
||||
|
||||
### 5. Lazy Fallback: On-Demand UUID Resolution
|
||||
|
||||
@@ -205,11 +234,12 @@ impl EphemeralIndex {
|
||||
/// Used when reconciliation hasn't completed yet.
|
||||
pub async fn get_or_resolve_uuid(
|
||||
&mut self,
|
||||
library_id: Uuid,
|
||||
path: &PathBuf,
|
||||
persistent_lookup: Option<&dyn PersistentUuidLookup>,
|
||||
) -> Option<Uuid> {
|
||||
// Fast path: already have a UUID (either generated or reconciled)
|
||||
if let Some(uuid) = self.get_entry_uuid(path) {
|
||||
if let Some(uuid) = self.get_entry_uuid(library_id, path) {
|
||||
return Some(uuid);
|
||||
}
|
||||
|
||||
@@ -218,7 +248,10 @@ impl EphemeralIndex {
|
||||
if let Some(persistent_uuid) = lookup.lookup_uuid(path).await {
|
||||
// Cache for future access
|
||||
if let Some(&entry_id) = self.path_index.get(path) {
|
||||
self.entry_uuids.insert(entry_id, persistent_uuid);
|
||||
self.entry_uuids_by_library
|
||||
.entry(library_id)
|
||||
.or_default()
|
||||
.insert(entry_id, persistent_uuid);
|
||||
}
|
||||
return Some(persistent_uuid);
|
||||
}
|
||||
@@ -240,7 +273,7 @@ pub trait PersistentUuidLookup: Send + Sync {
|
||||
|
||||
### 6. Emit Events on UUID Reconciliation
|
||||
|
||||
When a UUID changes from a temporary v4 to a persistent UUID, emit a `ResourceChanged` event so the frontend updates references.
|
||||
When a UUID changes from a temporary library-local v4 to a persistent UUID, emit a `ResourceChanged` event for that library/session so the frontend updates references.
|
||||
|
||||
```rust
|
||||
// In reconcile_persistent_uuids(), collect changed entries:
|
||||
@@ -262,6 +295,7 @@ This is important because the frontend may have cached the temporary UUID in sel
|
||||
- `core/src/ops/indexing/ephemeral/cache.rs` - Add `reconcile_with_persistent()`
|
||||
- `core/src/ops/indexing/job.rs` - Wire reconciliation after ephemeral completion
|
||||
- `core/src/ops/indexing/mod.rs` - Add `reconciliation` module
|
||||
- Ephemeral query/search call sites - Pass `library_id` into UUID reads and lazy assignment
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -271,9 +305,11 @@ This is important because the frontend may have cached the temporary UUID in sel
|
||||
- [ ] Lazy fallback resolves persistent UUIDs on demand when reconciliation hasn't completed
|
||||
- [ ] ResourceChanged events emitted when ephemeral UUIDs are replaced with persistent ones
|
||||
- [ ] Tags and metadata attached to persistent entries are visible in ephemeral views after reconciliation
|
||||
- [ ] Multiple libraries with overlapping paths are handled (all checked)
|
||||
- [ ] Multiple loaded libraries with overlapping paths are handled via separate UUID overlays
|
||||
- [ ] Reconciliation for one library does not overwrite UUIDs returned for another loaded library
|
||||
- [ ] Paths with no persistent overlap are unaffected (keep v4 UUIDs)
|
||||
- [ ] Integration test: ephemeral index of persistent location produces same UUIDs
|
||||
- [ ] Integration test: two loaded libraries can reconcile the same path to different UUIDs
|
||||
- [ ] Integration test: volume index reconciles UUIDs for all persistent locations on volume
|
||||
- [ ] Performance: reconciliation of 100K entries completes in under 2 seconds
|
||||
|
||||
@@ -291,9 +327,15 @@ Users expect ephemeral browsing to feel instant. Reconciliation involves databas
|
||||
|
||||
A persistent location at `/Users/james/Documents` overlaps with an ephemeral scan of `/Users/james` (the ephemeral path is a parent). The reconciliation needs to check both directions: persistent roots that are children of the scanned path, and persistent roots that are parents of the scanned path.
|
||||
|
||||
### Multiple Loaded Libraries
|
||||
|
||||
Core does not know an active library. It only knows loaded libraries and library-scoped operations. Directory listing, search, and volume indexing must pass the library ID from their operation context into ephemeral UUID access.
|
||||
|
||||
Do not reconcile against all loaded libraries into a single global `path -> uuid` map. That would make whichever library reconciles last win, causing the wrong tags and metadata to appear for other libraries.
|
||||
|
||||
### Memory Impact
|
||||
|
||||
The `entry_uuids` HashMap already exists in the ephemeral index. Reconciliation doesn't add new entries — it replaces v4 UUIDs with persistent ones. No additional memory overhead.
|
||||
The path tree, metadata, name cache, and content kind storage remain shared. Only UUID mappings become per-library. Memory overhead is proportional to the number of ephemeral entries that have been viewed or reconciled in each loaded library, not to the full filesystem metadata structure.
|
||||
|
||||
## Related Tasks
|
||||
|
||||
|
||||
42
.tasks/core/SHARE-000-public-shares-epic.md
Normal file
42
.tasks/core/SHARE-000-public-shares-epic.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: SHARE-000
|
||||
title: "Epic: Public Shares via sd.app"
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
priority: High
|
||||
tags: [epic, sharing, cloud, networking]
|
||||
related_tasks: [CLOUD-000, CLOUD-002, SEC-007, NET-001]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Public sharing of Spacedrive Spaces (and individual files/folders) via sd.app. A user creates a share from their local instance, gets a `sd.app/s/{token}` link, and visitors browse/download content in a web viewer. sd.app provides the Iroh relay, share registry, viewer SPA, and account management. Bytes never transit sd.app — the browser dials the user's core directly through our relay over QUIC.
|
||||
|
||||
## Architecture (decided)
|
||||
|
||||
- **Bytes path**: direct-dial. Browser → sd.app relay → user's core (QUIC). sd.app does not proxy file content.
|
||||
- **sd.app responsibilities**: Iroh relay infrastructure, share registry (token → node_id/relay_url), public viewer SPA, user accounts for share management.
|
||||
- **Share unit**: Spaces (primary), plus single files and arbitrary folder selections.
|
||||
- **Permissions**: read-only public, optional password, optional expiry.
|
||||
- **Repo layout**: sd.app cloud service lives in a separate repo; this epic covers core + desktop interface only.
|
||||
|
||||
## Sub-tasks
|
||||
|
||||
- `SHARE-001` — Design: share architecture & wire protocol spec
|
||||
- `SHARE-002` — Core: PublicShare schema, sync, migrations
|
||||
- `SHARE-003` — Core: share CRUD operations & RPC surface
|
||||
- `SHARE-004` — Core: custom relay configuration (sd.app)
|
||||
- `SHARE-005` — Core: guest access protocol over Iroh
|
||||
- `SHARE-006` — Core: file/folder share scope (beyond Spaces)
|
||||
- `SHARE-007` — Core: expiry & revocation enforcement
|
||||
- `SHARE-008` — Core: share registration with sd.app registry
|
||||
- `SHARE-009` — Interface: share creation UI
|
||||
- `SHARE-010` — Interface: share management view
|
||||
- `SHARE-011` — Core + Interface: sd.app account linking
|
||||
|
||||
## Related Work
|
||||
|
||||
- `CLOUD-000` — Cloud as a Peer (parent rationale)
|
||||
- `CLOUD-002` — Asynchronous Relay Server (re-scoped; see SHARE-004 note)
|
||||
- `SEC-007` — Per-Library Encryption Policies for Public Sharing
|
||||
- `NET-001` — Iroh P2P stack (foundation, already Done)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: SHARE-001
|
||||
title: "Design: Share Architecture & Wire Protocols"
|
||||
status: In Progress
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, design, protocol, cloud]
|
||||
last_updated: 2026-05-25
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Produce the contracts between core, sd.app, and the browser viewer. This is the source-of-truth design that both repos build against.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Sequence diagrams** for: share creation, share resolution (visitor lands on link), file listing, file streaming, share revocation, share expiry.
|
||||
2. **Wire protocol: core ↔ sd.app registry** (HTTPS / signed requests)
|
||||
- `POST /api/shares` — `{token, node_id, relay_url, public_metadata, password_required, expires_at}`
|
||||
- `POST /api/shares/{token}/heartbeat` — refresh relay address
|
||||
- `DELETE /api/shares/{token}` — revoke
|
||||
- `GET /s/{token}` — public resolver returning dial info to the viewer
|
||||
- Authenticated via device-key signature; rate-limited per device
|
||||
3. **Wire protocol: browser viewer ↔ core** (over Iroh QUIC, new ALPN `sd/share/1`)
|
||||
- Capability handshake (token + optional password proof)
|
||||
- `list_contents(path)` — directory listing scoped to share root
|
||||
- `get_metadata(path)` — file metadata
|
||||
- `read_range(path, offset, length)` — byte range stream
|
||||
- All requests gated by share scope on the core side
|
||||
4. **Token format**: 128-bit CSPRNG → base32 (no padding, no ambiguous chars). URL shape: `https://sd.app/s/{token}`. Optional `#k={key}` fragment reserved for client-side decryption keys (never sent to server).
|
||||
5. **Password handling**: argon2id hash stored on core. Password proof = HMAC over server-issued challenge nonce; password never sent in cleartext.
|
||||
6. **Public metadata**: name, item count, optional cover image, password_required flag. Owner identity is NOT exposed by default.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `docs/design/shares.md` with diagrams and protocol specs
|
||||
- ALPN string reserved for guest protocol
|
||||
- API schema (typed Rust structs + JSON schema) shared between core and sd.app
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Design document covers all six sequence diagrams listed above
|
||||
- [ ] Wire protocols are typed and machine-checkable on both sides
|
||||
- [ ] Token / URL / password protocols are specified with rationale
|
||||
- [ ] Design is approved before SHARE-002+ begins
|
||||
36
.tasks/core/SHARE-002-public-share-schema.md
Normal file
36
.tasks/core/SHARE-002-public-share-schema.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: SHARE-002
|
||||
title: PublicShare Schema, Sync, Migrations
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, database, sync]
|
||||
related_tasks: [SEC-007]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Database entity and sync support for `PublicShare`. Shares are owner-scoped within a library and sync across the owner's devices so any device can serve the share when online.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. New entity `core/src/infra/db/entities/public_share.rs`:
|
||||
- `id` UUID, `token` (unique, indexed)
|
||||
- `target_kind` enum: `Space` | `Entry` | `Path`
|
||||
- `target_ref` (UUID or sdpath, depending on kind)
|
||||
- `password_hash` nullable, `password_kdf_params` nullable
|
||||
- `expires_at` nullable, `revoked_at` nullable
|
||||
- `permissions` bitfield (read, download, list)
|
||||
- `created_at`, `created_by_device`, `last_published_at`
|
||||
- `public_metadata` JSON (name override, cover image entry id)
|
||||
2. Migration under `core/src/infra/db/migration/`
|
||||
3. Implement `Syncable` trait so share metadata flows across owner's devices in the library
|
||||
4. Indexes: `token` unique, `(target_kind, target_ref, revoked_at)`, `(expires_at)`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Migration applies cleanly on existing libraries
|
||||
- [ ] `PublicShare` rows sync across paired devices in the same library
|
||||
- [ ] Schema supports Space, single Entry, and arbitrary Path targets
|
||||
- [ ] Token field has DB-level uniqueness constraint
|
||||
32
.tasks/core/SHARE-003-share-crud-ops.md
Normal file
32
.tasks/core/SHARE-003-share-crud-ops.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: SHARE-003
|
||||
title: Share CRUD Operations
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, ops]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Library operations for managing shares from the desktop client. Each action is registered via `register_library_action!` and reached by the interface through the existing JSON-RPC daemon transport (`core/src/infra/daemon/rpc.rs`) — no separate router wiring needed.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Ops under `core/src/ops/shares/`:
|
||||
- `CreateShareAction` (`shares.create`) — generate token, hash password, persist row, trigger publish (SHARE-008)
|
||||
- `ListSharesAction` (`shares.list`) — filter by space / by device / all
|
||||
- `UpdateShareAction` (`shares.update`) — change expiry, password, permissions, metadata
|
||||
- `RevokeShareAction` (`shares.revoke`) — set `revoked_at`, push revocation to sd.app
|
||||
2. Token generation: 128-bit CSPRNG → base32, collision-check at insert
|
||||
3. Password handling: argon2id — add a `password` module to `crates/crypto` (the crate currently exposes only BLAKE3); thin wrapper over the `argon2` crate exposing `hash(password) -> PasswordHash` and `verify(password, hash) -> bool`
|
||||
4. Register each action with `register_library_action!`; type metadata is picked up automatically by the inventory-based extractor
|
||||
5. Authorization: only owner-class devices can create/revoke shares; enforced at op layer
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All four ops have unit tests covering happy path and auth boundary
|
||||
- [ ] Tokens are cryptographically random and uniqueness-enforced
|
||||
- [ ] Password updates re-hash with fresh salt
|
||||
- [ ] Revoking a share blocks subsequent guest access (verified in SHARE-005 tests)
|
||||
37
.tasks/core/SHARE-004-sdapp-relay-configuration.md
Normal file
37
.tasks/core/SHARE-004-sdapp-relay-configuration.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: SHARE-004
|
||||
title: Custom sd.app Relay Configuration
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, networking, iroh, relay, cloud]
|
||||
related_tasks: [CLOUD-002, NET-001]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Replace Iroh's default relay with sd.app-operated relays for hosted Spacedrive clients. Self-hosted users retain the option to point at Iroh defaults or their own relay.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `relay_config` to networking settings:
|
||||
- `Default` — Iroh network defaults
|
||||
- `SdApp` — sd.app relay pool URLs (default for hosted builds)
|
||||
- `Custom(Vec<RelayUrl>)`
|
||||
2. Plumb config through `NetworkingService` Iroh `Endpoint` construction
|
||||
3. Document sd.app relay endpoint URLs + region selection
|
||||
4. Surface current relay URL in `NetworkStatus` output (already partially exposed in `core/src/ops/network/status/output.rs`)
|
||||
5. CLI setting + interface setting under network preferences
|
||||
6. sd.app relay infrastructure itself is built in the separate sd.app repo; this task is purely the core-side configuration
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Default desktop builds use sd.app relays
|
||||
- [ ] Users can override via settings without recompiling
|
||||
- [ ] Existing pairing and file transfer flows continue to work over sd.app relays
|
||||
- [ ] Relay URL change takes effect without app restart where Iroh permits
|
||||
|
||||
## Notes
|
||||
|
||||
`CLOUD-002` is re-scoped: rather than building a store-and-forward relay, sd.app operates standard Iroh relays. The asynchronous coordination need is satisfied at the share-registry layer instead (see SHARE-008).
|
||||
35
.tasks/core/SHARE-005-guest-access-protocol.md
Normal file
35
.tasks/core/SHARE-005-guest-access-protocol.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: SHARE-005
|
||||
title: Guest Access Protocol over Iroh
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, networking, protocol, iroh]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
New ALPN and protocol handler in core to serve unauthenticated share visitors over Iroh QUIC streams. This is what the browser viewer dials.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Register ALPN `sd/share/1` on the Iroh endpoint
|
||||
2. Handler `core/src/service/network/protocol/share.rs`:
|
||||
- Handshake: client sends `{token, optional password_proof}`
|
||||
- Server validates token against `PublicShare`, checks `revoked_at` / `expires_at`, validates password proof against challenge nonce
|
||||
- On success, opens a scoped session bound to the share's target
|
||||
3. RPC over the session — matches existing protocol conventions (4-byte big-endian length prefix + JSON for control frames, 1MB cap; raw bytes follow a JSON header for binary payloads):
|
||||
- `ListContents { path }` — directory listing within share scope only
|
||||
- `GetMetadata { path }` — name, size, mime, thumbnail availability
|
||||
- `ReadRange { path, offset, length }` — JSON header `{bytes: N}` then N raw bytes; no base64 wrapping
|
||||
4. Scope enforcement: all paths resolved relative to the share target; reject path traversal and any access outside scope
|
||||
5. Thumbnails: serve from existing thumbnailer output if available, otherwise on-demand
|
||||
6. Integration tests `core/tests/share_guest_protocol_test.rs` — valid token, wrong password, revoked share, expired share, path traversal, scope escape, range reads over 1 GB
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A browser-style client can list and download share contents end-to-end through the relay
|
||||
- [ ] Token, password, expiry, and revocation gates are all enforced server-side
|
||||
- [ ] Path traversal attacks are rejected (covered by tests)
|
||||
- [ ] Range requests work for files of any size
|
||||
27
.tasks/core/SHARE-006-file-and-folder-share-scope.md
Normal file
27
.tasks/core/SHARE-006-file-and-folder-share-scope.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: SHARE-006
|
||||
title: File & Folder Share Scope
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: Medium
|
||||
tags: [sharing, scope]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Extend share creation beyond Spaces to support sharing a single file or an arbitrary folder selection.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `target_kind` already supports `Entry` and `Path` from SHARE-002
|
||||
2. Share-creation surface from explorer right-click menu (single file, folder, or multi-selection)
|
||||
3. For folder shares, the guest protocol resolves the share path as `/` from the visitor's perspective
|
||||
4. For multi-file selection: wrap the selection in an implicit ephemeral SpaceItem set tagged with the share id so the listing API does not need a separate code path
|
||||
5. Stable addressing: file shares are pinned by entry UUID, not path, so renames do not break the link
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can share a single file from the explorer right-click menu
|
||||
- [ ] User can share a multi-selection of files and folders as one share
|
||||
- [ ] Renaming a shared file or folder does not break the share link
|
||||
28
.tasks/core/SHARE-007-expiry-and-revocation.md
Normal file
28
.tasks/core/SHARE-007-expiry-and-revocation.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: SHARE-007
|
||||
title: Share Expiry & Revocation Enforcement
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: Medium
|
||||
tags: [sharing, lifecycle]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Enforce share lifecycle end-to-end: expiry, manual revocation, and propagation to sd.app so dead links resolve cleanly.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Expiry check on every guest handshake (cheap; same row lookup as auth)
|
||||
2. Background job at a fixed interval marks expired shares and notifies sd.app to remove them from the registry
|
||||
3. Revoke action immediately pushes deletion to sd.app and closes any in-flight guest sessions for that share
|
||||
4. Distinct `expired` and `revoked` states surfaced in the management UI (SHARE-010)
|
||||
5. sd.app side returns a clean "no longer available" page when registry returns 404/410
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Shares past `expires_at` reject guest handshakes
|
||||
- [ ] Manually revoked shares close in-flight sessions within seconds
|
||||
- [ ] sd.app registry is updated within seconds of revoke or expiry (when core is online)
|
||||
- [ ] Offline core: shares are unreachable, link resolution fails cleanly
|
||||
36
.tasks/core/SHARE-008-sdapp-registry-publish.md
Normal file
36
.tasks/core/SHARE-008-sdapp-registry-publish.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: SHARE-008
|
||||
title: Publish Shares to sd.app Registry
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, cloud, sync]
|
||||
related_tasks: [CLOUD-002]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Core publishes share metadata to sd.app's registry so `sd.app/s/{token}` can resolve to a dial target. Re-publishes on relay address changes; revokes on lifecycle changes.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Cloud client module `core/src/service/cloud/share_registry.rs`:
|
||||
- `publish(share)` — signed POST with `{token, node_id, relay_url, public_metadata}`
|
||||
- `refresh(token)` — heartbeat (e.g., every 10 minutes) so the registry has a fresh address
|
||||
- `revoke(token)`
|
||||
2. Authentication: per-device key signs requests (see SHARE-011 for account binding)
|
||||
3. Retry with backoff; queue in local DB so transient cloud outages do not drop publishes
|
||||
4. On core startup: reconcile by re-publishing any active local shares whose `last_published_at` is stale or whose relay URL has changed
|
||||
5. Wire the publish/revoke calls into `CreateShareAction`, `UpdateShareAction`, `RevokeShareAction`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Created shares appear in the sd.app registry within seconds (online case)
|
||||
- [ ] Relay URL changes are reflected in the registry on next heartbeat
|
||||
- [ ] Revocations are pushed immediately
|
||||
- [ ] Cloud outages do not block local share creation; queued publishes drain when cloud returns
|
||||
|
||||
## Notes
|
||||
|
||||
This is the only piece of the original "asynchronous relay" idea (CLOUD-002) we actually need: store-and-forward at the metadata layer, not the data layer.
|
||||
28
.tasks/core/SHARE-011-sdapp-account-linking.md
Normal file
28
.tasks/core/SHARE-011-sdapp-account-linking.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: SHARE-011
|
||||
title: sd.app Account Linking
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [sharing, cloud, accounts, auth]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Link a desktop device to an sd.app account so shares can be created, published, and managed from the web. v1 uses device-key-signed requests; the account binding is a lightweight `{account_id ↔ device_pubkey}` record on sd.app.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Desktop link flow: open browser to `sd.app/link?code=…`; user signs in or signs up; sd.app records `{device_pubkey → account_id}`
|
||||
2. Per-device session token cached locally, encrypted via the existing keyring integration
|
||||
3. Cloud client (`core/src/service/cloud/`) attaches the session token and signs requests with the device key
|
||||
4. Unlink/sign-out clears local credentials and notifies sd.app
|
||||
5. Multiple devices per account supported; any linked device can publish or revoke shares the account owns
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can link a desktop device to an sd.app account end-to-end
|
||||
- [ ] Linked device can create and publish shares
|
||||
- [ ] User can unlink without affecting other linked devices
|
||||
- [ ] Compromised device sessions can be revoked from sd.app; the desktop UI surfaces the linked-device list
|
||||
32
.tasks/interface/SHARE-009-interface-share-creation.md
Normal file
32
.tasks/interface/SHARE-009-interface-share-creation.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: SHARE-009
|
||||
title: "Interface: Share Creation UI"
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: High
|
||||
tags: [interface, sharing, ui]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Desktop UI for creating shares from Spaces, files, and folder selections.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. "Share" action in Space context menu and explorer right-click menu (including multi-select)
|
||||
2. Share dialog:
|
||||
- Permissions (read-only for v1)
|
||||
- Optional password
|
||||
- Optional expiry — presets (1 day / 7 days / 30 days / never) plus custom datetime
|
||||
- Cover image picker (defaults to space icon)
|
||||
3. On create: show generated `sd.app/s/{token}` link with copy and QR code
|
||||
4. Inline status if cloud is unreachable — share is created locally, link will resolve once SHARE-008 publishes
|
||||
5. Reuse existing component primitives in `packages/interface`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Share dialog reachable from Space context menu, file right-click, and multi-select right-click
|
||||
- [ ] Generated link is copyable and QR-renderable
|
||||
- [ ] Password and expiry inputs validated client-side
|
||||
- [ ] Works against a mocked cloud during development
|
||||
27
.tasks/interface/SHARE-010-interface-share-management.md
Normal file
27
.tasks/interface/SHARE-010-interface-share-management.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: SHARE-010
|
||||
title: "Interface: Share Management View"
|
||||
status: To Do
|
||||
assignee: jamiepine
|
||||
parent: SHARE-000
|
||||
priority: Medium
|
||||
tags: [interface, sharing, ui]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Per-Space and global "My Shares" view to inspect, edit, and revoke shares.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. "Active shares" tab on Space settings
|
||||
2. Global "Shares" route in settings, listing across all spaces
|
||||
3. Per-row actions: copy link, edit password/expiry, regenerate token (revoke old, mint new), revoke
|
||||
4. Show: created date, expiry, last accessed, access count (when sd.app provides them)
|
||||
5. Sync indicator per row: published / pending / failed (visualizes SHARE-008 state)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can see all active shares for a given Space
|
||||
- [ ] User can revoke a share and the link stops resolving within seconds
|
||||
- [ ] User can regenerate a token without losing the underlying share
|
||||
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -19,8 +19,10 @@ sd-core = { path = "../../core" }
|
||||
axum = "0.7"
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
http = "1.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "sync", "io-util"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "sync", "io-util", "fs"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
uuid = "1.10"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["fs", "cors"] }
|
||||
futures = "0.3"
|
||||
|
||||
@@ -40,6 +40,7 @@ struct WebAssets;
|
||||
struct AppState {
|
||||
auth: HashMap<String, SecStr>,
|
||||
socket_addr: String,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Basic auth middleware
|
||||
@@ -117,6 +118,103 @@ async fn serve_web(uri: Uri) -> Response {
|
||||
.expect("missing-bundle response is well-formed")
|
||||
}
|
||||
|
||||
/// Locate a library folder by its UUID. Library directory names are
|
||||
/// user-chosen, so each library.json is read to match the id.
|
||||
async fn find_library_folder(data_dir: &std::path::Path, library_id: &str) -> Option<PathBuf> {
|
||||
let mut entries = tokio::fs::read_dir(data_dir.join("libraries")).await.ok()?;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("sdlibrary") {
|
||||
continue;
|
||||
}
|
||||
let Ok(contents) = tokio::fs::read_to_string(path.join("library.json")).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
|
||||
continue;
|
||||
};
|
||||
if json.get("id").and_then(|v| v.as_str()) == Some(library_id) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn plain_status(status: StatusCode, message: &'static str) -> Response {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from(message))
|
||||
.expect("static status response is well-formed")
|
||||
}
|
||||
|
||||
/// Serve a sidecar file (thumbnail, thumbstrip, proxy, …) from the library's
|
||||
/// sidecars tree — the HTTP twin of the desktop app's local sidecar server,
|
||||
/// built on sd-core's canonical path scheme.
|
||||
async fn serve_sidecar(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((library_id, content_uuid, kind, variant_and_ext)): axum::extract::Path<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
)>,
|
||||
) -> Response {
|
||||
use sd_core::ops::sidecar::{SidecarFormat, SidecarKind, SidecarPathBuilder, SidecarVariant};
|
||||
|
||||
let Ok(content_uuid) = content_uuid.parse::<uuid::Uuid>() else {
|
||||
return plain_status(StatusCode::BAD_REQUEST, "invalid content uuid");
|
||||
};
|
||||
let Ok(kind) = SidecarKind::try_from(kind.as_str()) else {
|
||||
return plain_status(StatusCode::BAD_REQUEST, "invalid sidecar kind");
|
||||
};
|
||||
let Some((variant, ext)) = variant_and_ext.rsplit_once('.') else {
|
||||
return plain_status(StatusCode::BAD_REQUEST, "variant must carry an extension");
|
||||
};
|
||||
// The variant is the only free-form path segment; keep it a single segment.
|
||||
if variant.contains(['/', '\\']) || variant.contains("..") {
|
||||
return plain_status(StatusCode::BAD_REQUEST, "invalid variant");
|
||||
}
|
||||
let Ok(format) = SidecarFormat::try_from(ext) else {
|
||||
return plain_status(StatusCode::BAD_REQUEST, "invalid sidecar format");
|
||||
};
|
||||
let Some(library_folder) = find_library_folder(&state.data_dir, &library_id).await else {
|
||||
return plain_status(StatusCode::NOT_FOUND, "unknown library");
|
||||
};
|
||||
|
||||
let path = SidecarPathBuilder::new(&library_folder)
|
||||
.build(&content_uuid, &kind, &SidecarVariant::from(variant), &format)
|
||||
.absolute_path;
|
||||
|
||||
let Ok(file) = tokio::fs::File::open(&path).await else {
|
||||
return plain_status(StatusCode::NOT_FOUND, "sidecar not found");
|
||||
};
|
||||
let content_length = file.metadata().await.ok().map(|m| m.len());
|
||||
|
||||
let content_type = match format {
|
||||
SidecarFormat::Webp => "image/webp",
|
||||
SidecarFormat::Mp4 => "video/mp4",
|
||||
SidecarFormat::Json => "application/json",
|
||||
SidecarFormat::Text => "text/plain; charset=utf-8",
|
||||
SidecarFormat::MessagePack | SidecarFormat::Ply => "application/octet-stream",
|
||||
};
|
||||
|
||||
// Sidecar paths are content-addressed (uuid + variant), so clients may
|
||||
// cache indefinitely; regenerated variants land at the same path only
|
||||
// when the content itself is unchanged.
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||
if let Some(len) = content_length {
|
||||
builder = builder.header(header::CONTENT_LENGTH, len);
|
||||
}
|
||||
builder
|
||||
.body(Body::from_stream(tokio_util::io::ReaderStream::new(file)))
|
||||
.expect("sidecar response is well-formed")
|
||||
}
|
||||
|
||||
/// Bridge the daemon's event stream to a browser SSE connection.
|
||||
///
|
||||
/// Opens a dedicated TCP connection to the daemon, sends a Subscribe request
|
||||
@@ -261,6 +359,12 @@ struct Args {
|
||||
#[arg(long, env = "DATA_DIR")]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// Address to bind HTTP server. The wildcard default suits containers;
|
||||
/// bind a specific address (e.g. 127.0.0.1) when a reverse proxy such as
|
||||
/// `tailscale serve` owns the same port on other interfaces.
|
||||
#[arg(long, env = "SD_HOST", default_value = "::")]
|
||||
host: std::net::IpAddr,
|
||||
|
||||
/// Port to bind HTTP server (default: 8080)
|
||||
#[arg(long, env = "PORT", default_value = "8080")]
|
||||
port: u16,
|
||||
@@ -354,32 +458,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let state = AppState {
|
||||
auth,
|
||||
socket_addr: socket_addr.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/rpc", post(daemon_rpc))
|
||||
.route("/events", get(events_sse))
|
||||
.route(
|
||||
"/sidecar/:library_id/:content_uuid/:kind/*variant",
|
||||
get(serve_sidecar),
|
||||
)
|
||||
.fallback(serve_web)
|
||||
.layer(middleware::from_fn_with_state(state.clone(), basic_auth))
|
||||
.with_state(state);
|
||||
|
||||
// Bind server
|
||||
let mut addr = "[::]:8080".parse::<SocketAddr>().unwrap();
|
||||
addr.set_port(args.port);
|
||||
|
||||
info!(
|
||||
"Spacedrive Server listening on http://localhost:{}",
|
||||
args.port
|
||||
);
|
||||
info!("Web UI available at /");
|
||||
info!("RPC endpoint available at /rpc");
|
||||
let addr = SocketAddr::new(args.host, args.port);
|
||||
|
||||
// Setup graceful shutdown
|
||||
let shutdown_signal = shutdown_signal(daemon_handle);
|
||||
|
||||
// Start server
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!("Spacedrive Server listening on http://{}", addr);
|
||||
info!("Web UI available at /");
|
||||
info!("RPC endpoint available at /rpc");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal)
|
||||
.await?;
|
||||
|
||||
@@ -13,6 +13,18 @@ export const platform: Platform = {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
|
||||
// sd-server serves the UI, RPC, and sidecars from a single origin, so the
|
||||
// sidecar base URL is wherever this page was loaded from. Without this the
|
||||
// ServerContext never gets a serverUrl and thumbnails silently never load.
|
||||
async getDaemonStatus() {
|
||||
return {
|
||||
is_running: true,
|
||||
socket_path: "",
|
||||
server_url: window.location.origin,
|
||||
started_by_us: false,
|
||||
};
|
||||
},
|
||||
|
||||
confirm(message: string, callback: (result: boolean) => void) {
|
||||
callback(window.confirm(message));
|
||||
},
|
||||
|
||||
371
docs/design/shares.md
Normal file
371
docs/design/shares.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# Public Shares via sd.app
|
||||
|
||||
Status: draft for review. Source-of-truth contract between core (this repo) and the sd.app cloud service (separate repo).
|
||||
|
||||
## What this is
|
||||
|
||||
A user picks a Space, file, folder, or multi-selection in their local Spacedrive and creates a public share. They get a link of the form `https://sd.app/s/{token}`. Anyone with the link can open it in a browser, browse the listing, and stream/download files. Bytes are served directly from the user's core through an Iroh QUIC connection over an sd.app-operated relay — sd.app never proxies file content.
|
||||
|
||||
This document specifies the wire contracts and lifecycle. UX (`SHARE-009` / `SHARE-010`), schema (`SHARE-002`), ops (`SHARE-003`), and protocol handler (`SHARE-005`) tasks build against this spec.
|
||||
|
||||
## Decided architecture
|
||||
|
||||
- **Bytes path**: direct dial. Browser viewer dials the owner's `iroh::Endpoint` over QUIC, transiting an sd.app relay only when hole-punching fails. Bytes do not flow through sd.app application servers.
|
||||
- **sd.app responsibilities** (separate repo):
|
||||
1. Iroh relay infrastructure (replaces the n0 default relay for hosted clients)
|
||||
2. Share registry — maps `token → {node_id, relay_url, public_metadata}`
|
||||
3. Viewer SPA served at `sd.app/s/{token}`
|
||||
4. User accounts that bind one or more device keys to an `account_id`
|
||||
- **Core responsibilities** (this repo):
|
||||
1. `PublicShare` entity, sync across owner devices
|
||||
2. Share CRUD library actions
|
||||
3. Guest protocol handler on a new ALPN
|
||||
4. Heartbeat + revoke calls to the sd.app registry
|
||||
- **Trust boundary**: sd.app is treated as untrusted for content confidentiality. It learns the token, the public metadata the owner chose to expose, and the relay-level dial info. It does not learn share contents. Password proofs are HMAC-based; passwords never reach sd.app.
|
||||
|
||||
## Actors
|
||||
|
||||
```
|
||||
[Owner desktop] [sd.app cloud] [Visitor browser]
|
||||
core registry + viewer iroh-js + viewer SPA
|
||||
| | |
|
||||
| publish share | |
|
||||
|------------------->| |
|
||||
| | GET /s/{token} |
|
||||
| |<-----------------------------|
|
||||
| | dial info + metadata |
|
||||
| |----------------------------->|
|
||||
| | |
|
||||
| QUIC over Iroh relay (ALPN sd/share/1) |
|
||||
|<--------------------------------------------------|
|
||||
| listings, metadata, byte ranges |
|
||||
|-------------------------------------------------->|
|
||||
```
|
||||
|
||||
## Sequence diagrams
|
||||
|
||||
### 1. Share creation
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Desktop UI
|
||||
participant Core as Core (owner)
|
||||
participant Reg as sd.app registry
|
||||
UI->>Core: CreateShareAction { target, password?, expires_at? }
|
||||
Core->>Core: generate 128-bit token, argon2id(password)
|
||||
Core->>Core: persist PublicShare row, mark dirty for sync
|
||||
Core->>Reg: POST /api/shares { token, node_id, relay_url, public_metadata, password_required, expires_at } (device-signed)
|
||||
Reg-->>Core: 201 Created
|
||||
Core-->>UI: { url: "https://sd.app/s/{token}" }
|
||||
```
|
||||
|
||||
If `POST /api/shares` fails, the share is still created locally and queued for publish (see SHARE-008). The UI reflects "pending publish" until the registry confirms.
|
||||
|
||||
### 2. Share resolution (visitor lands on link)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant V as Visitor browser
|
||||
participant Reg as sd.app registry
|
||||
participant Viewer as Viewer SPA (sd.app)
|
||||
participant Core as Core (owner)
|
||||
V->>Reg: GET /s/{token}
|
||||
Reg-->>V: 200 HTML (viewer SPA) + bootstrap { node_id, relay_url, public_metadata, password_required }
|
||||
V->>Viewer: load SPA
|
||||
Viewer->>Core: Iroh dial, ALPN sd/share/1
|
||||
Note over Viewer,Core: handshake (token, optional password proof)
|
||||
Core-->>Viewer: session_ok or session_denied
|
||||
```
|
||||
|
||||
If the owner's core is offline, the viewer renders a "currently unavailable" state. The registry entry remains; the share resolves again as soon as the owner reconnects (the heartbeat refreshes `relay_url`).
|
||||
|
||||
### 3. Listing & metadata
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Viewer
|
||||
participant Core
|
||||
Viewer->>Core: ListContents { path: "/" }
|
||||
Core->>Core: resolve against share scope; reject traversal
|
||||
Core-->>Viewer: entries[]
|
||||
Viewer->>Core: GetMetadata { path: "/photo.jpg" }
|
||||
Core-->>Viewer: { size, mime, thumb_available }
|
||||
```
|
||||
|
||||
### 4. File streaming
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Viewer
|
||||
participant Core
|
||||
Viewer->>Core: ReadRange { path, offset, length }
|
||||
Core-->>Viewer: JSON header { bytes: N } \n N raw bytes
|
||||
```
|
||||
|
||||
`length == null` means "to EOF." Large transfers chunk into multiple `ReadRange` calls driven by the viewer (typically 4 MiB chunks).
|
||||
|
||||
### 5. Revocation
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI
|
||||
participant Core
|
||||
participant Reg
|
||||
participant Viewer
|
||||
UI->>Core: RevokeShareAction { share_id }
|
||||
Core->>Core: set revoked_at, sync to other owner devices
|
||||
Core->>Reg: DELETE /api/shares/{token}
|
||||
Reg-->>Core: 204
|
||||
Core->>Viewer: close in-flight QUIC streams for this token
|
||||
Note over Reg: subsequent GET /s/{token} → 410 Gone
|
||||
```
|
||||
|
||||
### 6. Expiry
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Core
|
||||
participant Reg
|
||||
Note over Core: background expiry job (every 1m)
|
||||
Core->>Core: select shares where expires_at < now and revoked_at is null
|
||||
Core->>Reg: DELETE /api/shares/{token} for each
|
||||
Note over Core: handshake also rejects expired tokens cheaply (same row lookup)
|
||||
```
|
||||
|
||||
## Core ↔ sd.app registry protocol (HTTPS)
|
||||
|
||||
Base URL: `https://sd.app/api`. All endpoints accept and return JSON. All requests other than the public resolver are authenticated by a detached Ed25519 signature over the canonicalized request body using the device key. The signature, device pubkey, and a request nonce live in headers:
|
||||
|
||||
```
|
||||
X-Sd-Device: base64(device_pubkey)
|
||||
X-Sd-Nonce: base64(16 random bytes)
|
||||
X-Sd-Signature: base64(ed25519_sign(device_key, "{method} {path}\n{nonce}\n{body_sha256}"))
|
||||
```
|
||||
|
||||
Rate limited per device key. Replays rejected via nonce cache (5 minute window).
|
||||
|
||||
### `POST /api/shares`
|
||||
|
||||
Create or upsert a share registration. Idempotent on `token`.
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "kf3p7q2x...", // base32, 26 chars
|
||||
"node_id": "iroh node id", // base32-z
|
||||
"relay_url": "https://relay-eu-west.sd.app",
|
||||
"public_metadata": {
|
||||
"name": "Iceland Trip",
|
||||
"item_count": 142,
|
||||
"cover_thumb_url": null, // optional, see Public metadata section
|
||||
"kind": "space" // "space" | "file" | "folder" | "selection"
|
||||
},
|
||||
"password_required": false,
|
||||
"expires_at": "2026-06-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Response `201`:
|
||||
```json
|
||||
{ "token": "kf3p7q2x...", "resolves_at": "https://sd.app/s/kf3p7q2x..." }
|
||||
```
|
||||
|
||||
Errors: `409` if another device tries to claim a token already bound to a different device pubkey for the same library.
|
||||
|
||||
### `POST /api/shares/{token}/heartbeat`
|
||||
|
||||
Refresh `relay_url` and `last_seen`. Called on a 10-minute interval while the owner is online, plus immediately on relay change.
|
||||
|
||||
```json
|
||||
{ "node_id": "...", "relay_url": "..." }
|
||||
```
|
||||
|
||||
Response `200 { "next_heartbeat_in": 600 }`.
|
||||
|
||||
If the registry returns `404`, the core re-publishes via `POST /api/shares` (covers registry data loss / cache eviction).
|
||||
|
||||
### `DELETE /api/shares/{token}`
|
||||
|
||||
Revoke. Idempotent. Response `204`. Subsequent public resolution returns `410 Gone`.
|
||||
|
||||
### `GET /s/{token}` (public)
|
||||
|
||||
Returns the viewer SPA HTML with a bootstrap JSON blob inlined:
|
||||
|
||||
```html
|
||||
<script id="sd-share-bootstrap" type="application/json">
|
||||
{
|
||||
"token": "kf3p7q2x...",
|
||||
"node_id": "...",
|
||||
"relay_url": "...",
|
||||
"public_metadata": { ... },
|
||||
"password_required": true
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
If `password_required`, the viewer prompts for the password, dials the core, and walks the challenge–response handshake described below — the registry has no part in password verification.
|
||||
|
||||
### `GET /api/shares` (owner)
|
||||
|
||||
Authenticated. Returns the device's published shares plus aggregate stats when available:
|
||||
|
||||
```json
|
||||
{
|
||||
"shares": [
|
||||
{
|
||||
"token": "...",
|
||||
"first_published_at": "...",
|
||||
"last_resolved_at": "...",
|
||||
"resolve_count_24h": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Viewer ↔ core protocol (Iroh QUIC)
|
||||
|
||||
ALPN: `sd/share/1`.
|
||||
|
||||
Framing matches existing protocol handlers in this repo (`core/src/service/network/protocol/pairing/mod.rs`): each frame is a 4-byte big-endian length prefix followed by a JSON body, capped at 1 MiB per frame. Binary payloads are sent as raw bytes immediately following a JSON header frame.
|
||||
|
||||
### Handshake
|
||||
|
||||
The visitor opens a QUIC bidirectional stream and sends:
|
||||
|
||||
```json
|
||||
{ "type": "hello", "token": "kf3p7q2x..." }
|
||||
```
|
||||
|
||||
For a tokenless or password-less share, core responds immediately with `session_ok` or `session_denied`.
|
||||
|
||||
If the share requires a password, core responds with a challenge:
|
||||
|
||||
```json
|
||||
{ "type": "challenge", "nonce": "base64(48 bytes)" }
|
||||
```
|
||||
|
||||
The nonce is self-validating: `nonce = random16 || unix_ts_be8 || hmac_sha256(server_secret, random16 || unix_ts_be8)[:24]`. The core doesn't need to track issued nonces — it re-verifies the inner HMAC and rejects nonces older than 5 minutes. This survives core restart without losing in-flight handshakes.
|
||||
|
||||
The visitor replies:
|
||||
|
||||
```json
|
||||
{ "type": "auth", "nonce": "...", "mac": "base64(hmac_sha256(K, nonce))" }
|
||||
```
|
||||
|
||||
where `K = argon2id(password, salt = sha256(token)[:16], params = OWASP-2024)` derived locally in the browser. The password itself never leaves the visitor.
|
||||
|
||||
The core's stored value for the share **is** `K` — `password_hash` is the raw argon2id output bytes, not a self-contained PHC string with random salt. Using a token-derived salt lets the visitor compute the same `K` without an extra round-trip for salt fetch. The core verifies by recomputing `hmac_sha256(stored_K, nonce)` and comparing in constant time.
|
||||
|
||||
Core responds:
|
||||
|
||||
```json
|
||||
{ "type": "session_ok", "session_id": "...", "scope": { "kind": "space" | "file" | "folder" | "selection", "root": "/" } }
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```json
|
||||
{ "type": "session_denied", "reason": "expired" | "revoked" | "bad_token" | "bad_password" }
|
||||
```
|
||||
|
||||
The session lives for the duration of the QUIC stream. Re-handshake on reconnect.
|
||||
|
||||
### Requests
|
||||
|
||||
All paths are visitor-facing virtual paths rooted at `/`. The core resolves them against the share's `target_kind` / `target_ref` and rejects anything escaping scope.
|
||||
|
||||
```json
|
||||
{ "type": "list_contents", "path": "/" }
|
||||
```
|
||||
→
|
||||
```json
|
||||
{
|
||||
"type": "list_contents_ok",
|
||||
"entries": [
|
||||
{ "name": "photo.jpg", "kind": "file", "size": 1048576, "mime": "image/jpeg", "thumb_available": true },
|
||||
{ "name": "sub", "kind": "folder" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "get_metadata", "path": "/photo.jpg" }
|
||||
```
|
||||
→
|
||||
```json
|
||||
{ "type": "get_metadata_ok", "size": 1048576, "mime": "image/jpeg", "modified_at": "...", "thumb_available": true }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "read_range", "path": "/photo.jpg", "offset": 0, "length": 4194304 }
|
||||
```
|
||||
→ JSON header frame:
|
||||
```json
|
||||
{ "type": "read_range_ok", "bytes": 4194304, "eof": false }
|
||||
```
|
||||
→ followed by 4194304 raw bytes on the same stream.
|
||||
|
||||
`length: null` streams until EOF, chunked into 1 MiB raw frames each preceded by its own `{ "type": "read_range_chunk", "bytes": N, "eof": bool }` header.
|
||||
|
||||
### Errors
|
||||
|
||||
```json
|
||||
{ "type": "error", "code": "scope_violation" | "not_found" | "internal", "message": "..." }
|
||||
```
|
||||
|
||||
Any `scope_violation` immediately terminates the session.
|
||||
|
||||
## Token format
|
||||
|
||||
- 128 bits of entropy from a CSPRNG (`getrandom`).
|
||||
- Encoded base32 using Crockford alphabet (no padding, excludes `I`, `L`, `O`, `U`).
|
||||
- Yields a 26-character string. URL shape: `https://sd.app/s/{token}`.
|
||||
- Optional fragment `#k={base64url(key)}` reserved for future client-side end-to-end encryption (`SEC-007`). Fragments are not sent to sd.app by browsers — preserves zero-knowledge property when used.
|
||||
|
||||
## Password handling
|
||||
|
||||
- **Storage on core**: `K = argon2id(password, salt = sha256(token)[:16], params)` with `params` per OWASP 2024 (`m=19456 KiB, t=2, p=1`). The token-derived salt is what lets the visitor compute the same `K` without a salt-fetch round-trip; per-share entropy comes from the token itself. `params` are stored in `password_kdf_params` so defaults can be rotated without breaking existing shares.
|
||||
- **Guest handshake** (see Handshake section above): challenge–response with `hmac_sha256(K, nonce)`. The stored `K` is both the verifier and the MAC key — anyone who reads the DB row can authenticate to that share, but the password is not recoverable from `K` (argon2id one-way). The HMAC nonce gives single-use proofs, so a proof leaked in transit (compromised browser extension etc.) can't be replayed.
|
||||
- **Owner-side preview** (the owner verifies their own password before showing it back, etc.): identical computation on a hello-world plaintext.
|
||||
- **Brute-force resistance**: argon2id memory cost + per-share salt + per-handshake nonce. Core additionally rate-limits handshake failures at 5/minute/token; further failures get `session_denied { reason: "rate_limited" }`.
|
||||
- **Why not store a separate verifier from the MAC key**: would require a salt-fetch round trip (token → salt) or threading the salt through the sd.app registry bootstrap. The token already carries 128 bits of entropy; using a token-derived salt achieves the same uniqueness without that round trip. The stored `K` being a bearer secret is no worse than any password-verifier scheme — DB exfiltration is a "game over" event regardless of construction.
|
||||
|
||||
## Public metadata
|
||||
|
||||
What sd.app receives at publish time and exposes via the resolver:
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `name` | yes | Visitor-visible title. Defaults to Space name; user-editable. |
|
||||
| `item_count` | yes | Top-level item count. |
|
||||
| `kind` | yes | `space` / `file` / `folder` / `selection`. |
|
||||
| `cover_thumb_url` | no | If owner opts in, core pre-uploads a cover thumbnail to sd.app's CDN and returns the URL here. Off by default. |
|
||||
| `password_required` | yes | Boolean. |
|
||||
| `expires_at` | no | ISO 8601. Surface in viewer. |
|
||||
|
||||
Owner identity (name, email, account_id) is **not** exposed by default. Optional `show_attribution` flag (future) can be added per share.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **Owner offline**: registry resolution succeeds but viewer cannot dial. Viewer shows "owner is offline; try again later." Heartbeat resumes on reconnect.
|
||||
- **Registry outage**: shares already cached by visitors continue to work (the SPA caches `node_id` + `relay_url` for the session). New visitors get the platform error page.
|
||||
- **Relay outage in one region**: visitor falls back to next-best relay via Iroh's relay map. Owner's next heartbeat publishes the new region.
|
||||
- **Token collision at create time**: re-roll. With 128 bits this is practically impossible (10^-15 at 10^9 active shares) but the DB unique constraint catches it deterministically.
|
||||
- **Owner device rotation**: the share is library-scoped and replicates via Sync, so any owner device can serve it. The first device online wins the heartbeat; the registry tracks the most recent `(node_id, relay_url)`. Cross-device handoff is a function of which device's heartbeat is freshest.
|
||||
|
||||
## Reserved identifiers
|
||||
|
||||
- ALPN: `sd/share/1`
|
||||
- Action kinds: `shares.create`, `shares.list`, `shares.update`, `shares.revoke`
|
||||
- DB table: `public_shares`
|
||||
- Sync entity name: `public_share`
|
||||
|
||||
## Open items
|
||||
|
||||
- **Pre-signed cover thumbnails**: spec'd as a `cover_thumb_url` field, but the upload mechanism (direct PUT to a presigned URL? via the registry?) is deferred to the sd.app repo design.
|
||||
- **End-to-end encryption** (`SEC-007`): the `#k=` fragment is reserved but the symmetric scheme and key wrapping for multi-recipient shares is out of scope for v1.
|
||||
- **Resolve analytics**: aggregate `resolve_count_24h` is in the owner API surface but the sd.app implementation will decide its retention and granularity.
|
||||
|
||||
## Versioning
|
||||
|
||||
`sd/share/1` is the v1 ALPN. Breaking changes get a new ALPN (`sd/share/2`); minor additions extend the JSON message types in a forward-compatible way (unknown fields ignored, unknown `type` returns a `protocol_error` error frame).
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ClockCounterClockwise,
|
||||
Cube,
|
||||
DotsThree,
|
||||
Eye,
|
||||
FilmStrip,
|
||||
Fingerprint,
|
||||
HardDrive,
|
||||
@@ -35,6 +36,7 @@ import {useJobsContext} from '../../../components/JobManager/hooks/JobsContext';
|
||||
import {TagSelectorButton} from '../../../components/Tags';
|
||||
import {usePlatform} from '../../../contexts/PlatformContext';
|
||||
import {useServer} from '../../../contexts/ServerContext';
|
||||
import {useOptionalExplorer} from '../../../routes/explorer';
|
||||
import { getContentKind } from "@sd/ts-client";
|
||||
import {
|
||||
getDeviceIcon,
|
||||
@@ -155,6 +157,8 @@ export function FileInspector({file}: FileInspectorProps) {
|
||||
// Quick Actions Component - Favorite, Share & Jobs buttons
|
||||
function FileQuickActions({file}: {file: File}) {
|
||||
const platform = usePlatform();
|
||||
// Null in the pop-out inspector window, which has no explorer to overlay.
|
||||
const explorer = useOptionalExplorer();
|
||||
const [isFavorite, setIsFavorite] = useState(false); // TODO: Get from file metadata
|
||||
|
||||
// AI Processing mutations
|
||||
@@ -292,6 +296,18 @@ function FileQuickActions({file}: {file: File}) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Quick Preview Button — spacebar isn't available on touch devices */}
|
||||
{explorer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => explorer.openQuickPreview(file.id)}
|
||||
className="border-sidebar-line/30 bg-sidebar-box/20 text-sidebar-inkDull hover:bg-sidebar-box/30 hover:text-sidebar-ink flex size-7 items-center justify-center rounded-full border transition-all active:scale-95"
|
||||
title="Quick Preview"
|
||||
>
|
||||
<Eye size={14} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Favorite Button */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -930,6 +930,15 @@ export function useExplorer(): ExplorerContextValue {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Null-safe variant for components that also render outside the explorer
|
||||
* (e.g. the pop-out inspector window), where explorer-bound actions should
|
||||
* disappear instead of crashing.
|
||||
*/
|
||||
export function useOptionalExplorer(): ExplorerContextValue | null {
|
||||
return useContext(ExplorerContext);
|
||||
}
|
||||
|
||||
export {
|
||||
getSpaceItemKey,
|
||||
getSpaceItemKey as getSpaceItemKeyFromRoute,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { ExplorerProvider, useExplorer, getSpaceItemKey, getSpaceItemKeyFromRoute, targetToKey, targetsEqual } from "./context";
|
||||
export { ExplorerProvider, useExplorer, useOptionalExplorer, getSpaceItemKey, getSpaceItemKeyFromRoute, targetToKey, targetsEqual } from "./context";
|
||||
export type { NavigationTarget, ViewMode, ViewSettings, SortBy } from "./context";
|
||||
export { SelectionProvider, useSelection } from "./SelectionContext";
|
||||
export { ExplorerView } from "./ExplorerView";
|
||||
|
||||
Reference in New Issue
Block a user