mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
feat: mount spaces/folders over SFTP with SSH-key auth
Add an in-process SFTP server (russh) backed directly by the nodes table, so a space can be edited from nvim or synced with sshfs without a separate filesystem mirror. Access is authenticated by public key against a new ssh_keys table, managed via :sshkey in the app; the feature stays off until WORKSPACE_SFTP_BIND is set.
This commit is contained in:
1 parent
ab8712559e
commit
747332153c
12 files changed
+1950
-93
No files matched your search
Generated
+1005
-91
File diff suppressed because it is too large.
Load diff
@@ -45,6 +45,15 @@ proton-sdk = "0.6.2"
|
|||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
||||||
|
# SFTP mount: spaces and folders as a filesystem, authenticated by pubkey
|
||||||
|
# instead of the cookie session. `ring` rather than the default `aws-lc-rs`
|
||||||
|
# backend, because rustls (via lettre) already pulls `ring` in.
|
||||||
|
russh = { version = "0.63", default-features = false, features = ["ring", "rsa"] }
|
||||||
|
russh-sftp = "3"
|
||||||
|
# Only for generating the host key at first boot — ssh-key needs an rng that
|
||||||
|
# speaks rand_core 0.10, one major version past the `rand` used everywhere else.
|
||||||
|
rand10 = { package = "rand", version = "0.10" }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# Keep the Proton session in the OS keyring instead of a 0600 file. Needs a
|
# Keep the Proton session in the OS keyring instead of a 0600 file. Needs a
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Public keys allowed to mount the workspace over SFTP. A single-user
|
||||||
|
-- application still wants more than one key: one per machine that mounts it,
|
||||||
|
-- so losing a laptop means revoking one row rather than a shared secret.
|
||||||
|
CREATE TABLE ssh_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- `ssh-ed25519 AAAA...`, comment stripped: the comment is not part of the
|
||||||
|
-- key and would otherwise make the same key look different twice.
|
||||||
|
public_key TEXT NOT NULL UNIQUE,
|
||||||
|
fingerprint TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
+2
-1
@@ -100,7 +100,7 @@ impl FromRequestParts<AppState> for Session {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{credentials_match, issue_token, verify_token};
|
use super::{credentials_match, issue_token, verify_token};
|
||||||
use crate::config::{BackupConfig, Config, MailConfig};
|
use crate::config::{BackupConfig, Config, MailConfig, SftpConfig};
|
||||||
|
|
||||||
fn config(session_days: i64) -> Config {
|
fn config(session_days: i64) -> Config {
|
||||||
Config {
|
Config {
|
||||||
@@ -128,6 +128,7 @@ mod tests {
|
|||||||
digest_at: None,
|
digest_at: None,
|
||||||
kinds: Vec::new(),
|
kinds: Vec::new(),
|
||||||
},
|
},
|
||||||
|
sftp: SftpConfig { bind: None },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ pub struct Config {
|
|||||||
pub secure_cookie: bool,
|
pub secure_cookie: bool,
|
||||||
pub backup: BackupConfig,
|
pub backup: BackupConfig,
|
||||||
pub mail: MailConfig,
|
pub mail: MailConfig,
|
||||||
|
pub sftp: SftpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SFTP mount. Dormant until `WORKSPACE_SFTP_BIND` is set — same shape as
|
||||||
|
/// the mail relay, because a workspace with no key registered yet should not
|
||||||
|
/// refuse to start over a feature nobody has turned on.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SftpConfig {
|
||||||
|
pub bind: Option<SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SftpConfig {
|
||||||
|
fn from_env() -> Result<Self> {
|
||||||
|
let bind = var("SFTP_BIND")
|
||||||
|
.map(|v| v.parse())
|
||||||
|
.transpose()
|
||||||
|
.context("WORKSPACE_SFTP_BIND must look like 127.0.0.1:2222")?;
|
||||||
|
Ok(Self { bind })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time of day in the configured timezone, as `HH:MM`.
|
/// Time of day in the configured timezone, as `HH:MM`.
|
||||||
@@ -194,6 +213,7 @@ impl Config {
|
|||||||
secure_cookie,
|
secure_cookie,
|
||||||
backup: BackupConfig::from_env()?,
|
backup: BackupConfig::from_env()?,
|
||||||
mail: MailConfig::from_env()?,
|
mail: MailConfig::from_env()?,
|
||||||
|
sftp: SftpConfig::from_env()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +226,12 @@ impl Config {
|
|||||||
pub fn proton_session_path(&self) -> PathBuf {
|
pub fn proton_session_path(&self) -> PathBuf {
|
||||||
self.data_dir.join("proton-session.json")
|
self.data_dir.join("proton-session.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The SFTP server's own identity, generated once on first boot. Losing it
|
||||||
|
/// only costs a host-key warning on every client, never data.
|
||||||
|
pub fn sftp_host_key_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("sftp_host_key")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailConfig {
|
impl MailConfig {
|
||||||
|
|||||||
+12
-1
@@ -6,6 +6,7 @@ mod error;
|
|||||||
mod mail;
|
mod mail;
|
||||||
mod notify;
|
mod notify;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod sftp;
|
||||||
mod state;
|
mod state;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
mod throttle;
|
mod throttle;
|
||||||
@@ -110,11 +111,21 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
config,
|
config: Arc::clone(&config),
|
||||||
login_throttle: Arc::default(),
|
login_throttle: Arc::default(),
|
||||||
backup,
|
backup,
|
||||||
notify,
|
notify,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(sftp_bind) = config.sftp.bind {
|
||||||
|
let sftp_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = sftp::serve(sftp_state, sftp_bind).await {
|
||||||
|
tracing::error!("sftp server stopped: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
serve(bind, max_upload, state).await
|
serve(bind, max_upload, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod import;
|
|||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
|
pub mod ssh_keys;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
@@ -41,4 +42,6 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||||
.route("/share/{token}/{key}", get(share::read))
|
.route("/share/{token}/{key}", get(share::read))
|
||||||
.route("/share/{token}/{key}/media/{id}", get(share::serve_media))
|
.route("/share/{token}/{key}/media/{id}", get(share::serve_media))
|
||||||
|
.route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add))
|
||||||
|
.route("/ssh-keys/{id}", delete(ssh_keys::remove))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::Session,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
sftp::fingerprint_of,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SshKeySummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub last_used_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AddInput {
|
||||||
|
pub name: String,
|
||||||
|
/// A line from `authorized_keys`: `ssh-ed25519 AAAA... comment`. The
|
||||||
|
/// comment is accepted but not kept — it is not part of the key, and
|
||||||
|
/// keeping it would make the same key compare unequal to itself.
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<SshKeySummary>>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, name, fingerprint, created_at, last_used_at FROM ssh_keys ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| SshKeySummary {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
fingerprint: row.get("fingerprint"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
last_used_at: row.get("last_used_at"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add(
|
||||||
|
_: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<AddInput>,
|
||||||
|
) -> AppResult<Json<SshKeySummary>> {
|
||||||
|
let name = input.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("a key needs a name".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (normalized, fingerprint) = fingerprint_of(&input.public_key)
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("not a public key: {e}")))?;
|
||||||
|
|
||||||
|
let now = crate::now();
|
||||||
|
let id: i64 = sqlx::query(
|
||||||
|
"INSERT INTO ssh_keys (name, public_key, fingerprint, created_at) VALUES (?1, ?2, ?3, ?4)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(&normalized)
|
||||||
|
.bind(&fingerprint)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::Database(db) if db.is_unique_violation() => {
|
||||||
|
AppError::BadRequest("that key is already registered".into())
|
||||||
|
}
|
||||||
|
e => e.into(),
|
||||||
|
})?
|
||||||
|
.get("id");
|
||||||
|
|
||||||
|
Ok(Json(SshKeySummary {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
created_at: now,
|
||||||
|
last_used_at: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
_: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let affected = sqlx::query("DELETE FROM ssh_keys WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
+578
@@ -0,0 +1,578 @@
|
|||||||
|
//! The virtual filesystem an SFTP client sees: `/<space>/<folder>/.../<doc>.md`.
|
||||||
|
//! Directories are spaces and folders; the only files are documents, always
|
||||||
|
//! `.md`. There is no on-disk mirror — every call reads or writes `nodes`
|
||||||
|
//! directly, so a save here is a save in the browser too.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use russh_sftp::protocol::{
|
||||||
|
File, FileAttributes, Handle, Name, OpenFlags, StatusCode, Version,
|
||||||
|
};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{routes::media, routes::nodes, state::AppState};
|
||||||
|
|
||||||
|
type SftpResult<T> = Result<T, StatusCode>;
|
||||||
|
|
||||||
|
/// A node as this filesystem sees it: enough to answer `stat` and to list a
|
||||||
|
/// parent directory without a second query.
|
||||||
|
struct Entry {
|
||||||
|
id: i64,
|
||||||
|
kind: String,
|
||||||
|
slug: String,
|
||||||
|
updated_at: i64,
|
||||||
|
size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filename(entry: &Entry) -> String {
|
||||||
|
if entry.kind == nodes::DOCUMENT {
|
||||||
|
format!("{}.md", entry.slug)
|
||||||
|
} else {
|
||||||
|
entry.slug.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_attrs(mtime: i64) -> FileAttributes {
|
||||||
|
let mut attrs = FileAttributes::empty();
|
||||||
|
attrs.set_dir(true);
|
||||||
|
attrs.permissions = Some(0o755 | attrs.permissions.unwrap_or(0));
|
||||||
|
attrs.mtime = Some(mtime as u32);
|
||||||
|
attrs.atime = Some(mtime as u32);
|
||||||
|
attrs.size = Some(0);
|
||||||
|
attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_attrs(size: u64, mtime: i64) -> FileAttributes {
|
||||||
|
let mut attrs = FileAttributes::empty();
|
||||||
|
attrs.set_regular(true);
|
||||||
|
attrs.permissions = Some(0o644 | attrs.permissions.unwrap_or(0));
|
||||||
|
attrs.mtime = Some(mtime as u32);
|
||||||
|
attrs.atime = Some(mtime as u32);
|
||||||
|
attrs.size = Some(size);
|
||||||
|
attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attrs_of(entry: &Entry) -> FileAttributes {
|
||||||
|
if entry.kind == nodes::DOCUMENT {
|
||||||
|
file_attrs(entry.size as u64, entry.updated_at)
|
||||||
|
} else {
|
||||||
|
dir_attrs(entry.updated_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path split into segments, `/` and `.` collapsed away. `readlink`,
|
||||||
|
/// symlinks and anything OpenSSH probes for are simply not implemented —
|
||||||
|
/// there are none of those here.
|
||||||
|
fn segments(path: &str) -> Vec<&str> {
|
||||||
|
path.split('/').filter(|s| !s.is_empty() && *s != ".").collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document's slug is its filename without the trailing `.md`. A filename
|
||||||
|
/// that does not end in `.md` never resolves — this filesystem holds no other
|
||||||
|
/// kind of file yet.
|
||||||
|
fn doc_slug(filename: &str) -> Option<&str> {
|
||||||
|
filename.strip_suffix(".md").filter(|base| !base.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Entry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Entry {
|
||||||
|
id: 0,
|
||||||
|
kind: nodes::SPACE.to_string(),
|
||||||
|
slug: String::new(),
|
||||||
|
updated_at: 0,
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OpenHandle {
|
||||||
|
/// Directory listing, already fetched: `readdir` just drains it.
|
||||||
|
Dir(Vec<Entry>),
|
||||||
|
/// A document's body, fetched once at `open`. Read-only handles never
|
||||||
|
/// touch the database again after this.
|
||||||
|
Read(Vec<u8>),
|
||||||
|
/// A document being written. `id` is `None` for a file that does not
|
||||||
|
/// exist yet — it is only created in `nodes` when the handle closes,
|
||||||
|
/// so an editor's write-then-fsync-then-close never leaves a half
|
||||||
|
/// document with the wrong name behind.
|
||||||
|
Write {
|
||||||
|
id: Option<i64>,
|
||||||
|
parent_id: i64,
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
buf: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SpaceFs {
|
||||||
|
state: AppState,
|
||||||
|
handles: HashMap<String, OpenHandle>,
|
||||||
|
next_handle: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpaceFs {
|
||||||
|
pub fn new(state: AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
handles: HashMap::new(),
|
||||||
|
next_handle: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate(&mut self, handle: OpenHandle) -> String {
|
||||||
|
self.next_handle += 1;
|
||||||
|
let key = self.next_handle.to_string();
|
||||||
|
self.handles.insert(key.clone(), handle);
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a path of directory segments to a node id, `None` meaning the
|
||||||
|
/// root — which holds the spaces and nothing else.
|
||||||
|
async fn resolve_dir(&self, segs: &[&str]) -> SftpResult<Option<i64>> {
|
||||||
|
let mut current: Option<i64> = None;
|
||||||
|
for seg in segs {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2 AND kind <> 'document'",
|
||||||
|
)
|
||||||
|
.bind(current)
|
||||||
|
.bind(seg)
|
||||||
|
.fetch_optional(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?
|
||||||
|
.ok_or(StatusCode::NoSuchFile)?;
|
||||||
|
current = Some(row.get("id"));
|
||||||
|
}
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a path into the directory that must already exist and the
|
||||||
|
/// trailing segment, still unresolved.
|
||||||
|
fn split_path(path: &str) -> (Vec<&str>, Option<&str>) {
|
||||||
|
let mut segs = segments(path);
|
||||||
|
let leaf = segs.pop();
|
||||||
|
(segs, leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entry_at(&self, path: &str) -> SftpResult<Entry> {
|
||||||
|
let segs = segments(path);
|
||||||
|
if segs.is_empty() {
|
||||||
|
return Ok(Entry::default());
|
||||||
|
}
|
||||||
|
let (dir, leaf) = Self::split_path(path);
|
||||||
|
let Some(leaf) = leaf else {
|
||||||
|
return Ok(Entry::default());
|
||||||
|
};
|
||||||
|
let parent = self.resolve_dir(&dir).await;
|
||||||
|
|
||||||
|
// A directory: space or folder, matched by slug under its parent.
|
||||||
|
if let Ok(parent_id) = parent {
|
||||||
|
let dir_row = sqlx::query(
|
||||||
|
"SELECT id, kind, slug, updated_at FROM nodes
|
||||||
|
WHERE parent_id IS ?1 AND slug = ?2 AND kind <> 'document'",
|
||||||
|
)
|
||||||
|
.bind(parent_id)
|
||||||
|
.bind(leaf)
|
||||||
|
.fetch_optional(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
if let Some(row) = dir_row {
|
||||||
|
return Ok(Entry {
|
||||||
|
id: row.get("id"),
|
||||||
|
kind: row.get("kind"),
|
||||||
|
slug: row.get("slug"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(slug) = doc_slug(leaf) {
|
||||||
|
let doc_row = sqlx::query(
|
||||||
|
"SELECT id, slug, updated_at, length(body) AS size FROM nodes
|
||||||
|
WHERE parent_id IS ?1 AND slug = ?2 AND kind = 'document'",
|
||||||
|
)
|
||||||
|
.bind(parent_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
if let Some(row) = doc_row {
|
||||||
|
return Ok(Entry {
|
||||||
|
id: row.get("id"),
|
||||||
|
kind: nodes::DOCUMENT.to_string(),
|
||||||
|
slug: row.get("slug"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
size: row.get("size"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(StatusCode::NoSuchFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn children(&self, parent_id: Option<i64>) -> SftpResult<Vec<Entry>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, kind, slug, updated_at, length(body) AS size FROM nodes
|
||||||
|
WHERE parent_id IS ?1 ORDER BY kind, slug",
|
||||||
|
)
|
||||||
|
.bind(parent_id)
|
||||||
|
.fetch_all(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| Entry {
|
||||||
|
id: row.get("id"),
|
||||||
|
kind: row.get("kind"),
|
||||||
|
slug: row.get("slug"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
size: row.try_get("size").unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl russh_sftp::server::Handler for SpaceFs {
|
||||||
|
type Error = StatusCode;
|
||||||
|
|
||||||
|
fn unimplemented(&self) -> Self::Error {
|
||||||
|
StatusCode::OpUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn init(
|
||||||
|
&mut self,
|
||||||
|
_version: u32,
|
||||||
|
_extensions: HashMap<String, String>,
|
||||||
|
) -> SftpResult<Version> {
|
||||||
|
Ok(Version::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn realpath(&mut self, id: u32, path: String) -> SftpResult<Name> {
|
||||||
|
let clean = segments(&path).join("/");
|
||||||
|
Ok(Name {
|
||||||
|
id,
|
||||||
|
files: vec![File::dummy(format!("/{clean}"))],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lstat(&mut self, id: u32, path: String) -> SftpResult<russh_sftp::protocol::Attrs> {
|
||||||
|
self.stat(id, path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat(&mut self, id: u32, path: String) -> SftpResult<russh_sftp::protocol::Attrs> {
|
||||||
|
let entry = self.entry_at(&path).await?;
|
||||||
|
Ok(russh_sftp::protocol::Attrs {
|
||||||
|
id,
|
||||||
|
attrs: attrs_of(&entry),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fstat(&mut self, id: u32, handle: String) -> SftpResult<russh_sftp::protocol::Attrs> {
|
||||||
|
match self.handles.get(&handle) {
|
||||||
|
Some(OpenHandle::Read(data)) => Ok(russh_sftp::protocol::Attrs {
|
||||||
|
id,
|
||||||
|
attrs: file_attrs(data.len() as u64, crate::now()),
|
||||||
|
}),
|
||||||
|
Some(OpenHandle::Write { buf, .. }) => Ok(russh_sftp::protocol::Attrs {
|
||||||
|
id,
|
||||||
|
attrs: file_attrs(buf.len() as u64, crate::now()),
|
||||||
|
}),
|
||||||
|
Some(OpenHandle::Dir(_)) => Ok(russh_sftp::protocol::Attrs {
|
||||||
|
id,
|
||||||
|
attrs: dir_attrs(crate::now()),
|
||||||
|
}),
|
||||||
|
None => Err(StatusCode::Failure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn opendir(&mut self, id: u32, path: String) -> SftpResult<Handle> {
|
||||||
|
let segs = segments(&path);
|
||||||
|
let parent_id = self.resolve_dir(&segs).await?;
|
||||||
|
let entries = self.children(parent_id).await?;
|
||||||
|
let handle = self.allocate(OpenHandle::Dir(entries));
|
||||||
|
let _ = id;
|
||||||
|
Ok(Handle { id, handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn readdir(&mut self, id: u32, handle: String) -> SftpResult<Name> {
|
||||||
|
match self.handles.get_mut(&handle) {
|
||||||
|
Some(OpenHandle::Dir(entries)) if !entries.is_empty() => {
|
||||||
|
let files = entries
|
||||||
|
.drain(..)
|
||||||
|
.map(|entry| File::new(filename(&entry), attrs_of(&entry)))
|
||||||
|
.collect();
|
||||||
|
Ok(Name { id, files })
|
||||||
|
}
|
||||||
|
Some(OpenHandle::Dir(_)) => Err(StatusCode::Eof),
|
||||||
|
_ => Err(StatusCode::Failure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open(
|
||||||
|
&mut self,
|
||||||
|
id: u32,
|
||||||
|
filename: String,
|
||||||
|
pflags: OpenFlags,
|
||||||
|
_attrs: FileAttributes,
|
||||||
|
) -> SftpResult<Handle> {
|
||||||
|
let (dir, leaf) = Self::split_path(&filename);
|
||||||
|
let leaf = leaf.ok_or(StatusCode::PermissionDenied)?;
|
||||||
|
let slug = doc_slug(leaf).ok_or(StatusCode::PermissionDenied)?;
|
||||||
|
let parent_id = self.resolve_dir(&dir).await?;
|
||||||
|
|
||||||
|
let existing = sqlx::query(
|
||||||
|
"SELECT id, body FROM nodes WHERE parent_id IS ?1 AND slug = ?2 AND kind = 'document'",
|
||||||
|
)
|
||||||
|
.bind(parent_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
|
||||||
|
if pflags.contains(OpenFlags::WRITE) {
|
||||||
|
if existing.is_none() && !pflags.contains(OpenFlags::CREATE) {
|
||||||
|
return Err(StatusCode::NoSuchFile);
|
||||||
|
}
|
||||||
|
if existing.is_some() && pflags.contains(OpenFlags::EXCLUDE) {
|
||||||
|
return Err(StatusCode::Failure);
|
||||||
|
}
|
||||||
|
let Some(parent_id) = parent_id else {
|
||||||
|
// A document cannot live loose at the root — only spaces do.
|
||||||
|
return Err(StatusCode::PermissionDenied);
|
||||||
|
};
|
||||||
|
let buf = match &existing {
|
||||||
|
Some(row) if !pflags.contains(OpenFlags::TRUNCATE) => {
|
||||||
|
row.get::<String, _>("body").into_bytes()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let handle = self.allocate(OpenHandle::Write {
|
||||||
|
id: existing.map(|row| row.get("id")),
|
||||||
|
parent_id,
|
||||||
|
slug: slug.to_string(),
|
||||||
|
name: slug.to_string(),
|
||||||
|
buf,
|
||||||
|
});
|
||||||
|
return Ok(Handle { id, handle });
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = existing.ok_or(StatusCode::NoSuchFile)?;
|
||||||
|
let body: String = row.get("body");
|
||||||
|
let handle = self.allocate(OpenHandle::Read(body.into_bytes()));
|
||||||
|
Ok(Handle { id, handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(
|
||||||
|
&mut self,
|
||||||
|
id: u32,
|
||||||
|
handle: String,
|
||||||
|
offset: u64,
|
||||||
|
len: u32,
|
||||||
|
) -> SftpResult<russh_sftp::protocol::Data> {
|
||||||
|
let data = match self.handles.get(&handle) {
|
||||||
|
Some(OpenHandle::Read(data)) => data,
|
||||||
|
_ => return Err(StatusCode::Failure),
|
||||||
|
};
|
||||||
|
let offset = offset as usize;
|
||||||
|
if offset >= data.len() {
|
||||||
|
return Err(StatusCode::Eof);
|
||||||
|
}
|
||||||
|
let end = (offset + len as usize).min(data.len());
|
||||||
|
Ok(russh_sftp::protocol::Data {
|
||||||
|
id,
|
||||||
|
data: data[offset..end].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(
|
||||||
|
&mut self,
|
||||||
|
id: u32,
|
||||||
|
handle: String,
|
||||||
|
offset: u64,
|
||||||
|
data: Vec<u8>,
|
||||||
|
) -> SftpResult<russh_sftp::protocol::Status> {
|
||||||
|
match self.handles.get_mut(&handle) {
|
||||||
|
Some(OpenHandle::Write { buf, .. }) => {
|
||||||
|
let offset = offset as usize;
|
||||||
|
if buf.len() < offset {
|
||||||
|
buf.resize(offset, 0);
|
||||||
|
}
|
||||||
|
let end = offset + data.len();
|
||||||
|
if buf.len() < end {
|
||||||
|
buf.resize(end, 0);
|
||||||
|
}
|
||||||
|
buf[offset..end].copy_from_slice(&data);
|
||||||
|
Ok(ok_status(id))
|
||||||
|
}
|
||||||
|
_ => Err(StatusCode::Failure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self, id: u32, handle: String) -> SftpResult<russh_sftp::protocol::Status> {
|
||||||
|
let Some(open) = self.handles.remove(&handle) else {
|
||||||
|
return Err(StatusCode::Failure);
|
||||||
|
};
|
||||||
|
|
||||||
|
let OpenHandle::Write { id: node_id, parent_id, slug, name, buf } = open else {
|
||||||
|
return Ok(ok_status(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = String::from_utf8(buf).map_err(|_| StatusCode::Failure)?;
|
||||||
|
let now = crate::now();
|
||||||
|
|
||||||
|
match node_id {
|
||||||
|
Some(node_id) => {
|
||||||
|
sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3")
|
||||||
|
.bind(&body)
|
||||||
|
.bind(now)
|
||||||
|
.bind(node_id)
|
||||||
|
.execute(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
media::link_to_node(&self.state.db, node_id, &body)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let slug = nodes::unique_slug(&self.state.db, Some(parent_id), &slug, None)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
let new_id: i64 = sqlx::query(
|
||||||
|
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
||||||
|
VALUES (?1, 'document', ?2, ?3, ?4, ?5, ?5) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(parent_id)
|
||||||
|
.bind(&name)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&body)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?
|
||||||
|
.get("id");
|
||||||
|
media::link_to_node(&self.state.db, new_id, &body).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state.backup.signal();
|
||||||
|
Ok(ok_status(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(&mut self, id: u32, filename: String) -> SftpResult<russh_sftp::protocol::Status> {
|
||||||
|
let entry = self.entry_at(&filename).await?;
|
||||||
|
if entry.kind != nodes::DOCUMENT {
|
||||||
|
return Err(StatusCode::PermissionDenied);
|
||||||
|
}
|
||||||
|
sqlx::query("DELETE FROM nodes WHERE id = ?1")
|
||||||
|
.bind(entry.id)
|
||||||
|
.execute(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
self.state.backup.signal();
|
||||||
|
Ok(ok_status(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mkdir(
|
||||||
|
&mut self,
|
||||||
|
id: u32,
|
||||||
|
path: String,
|
||||||
|
_attrs: FileAttributes,
|
||||||
|
) -> SftpResult<russh_sftp::protocol::Status> {
|
||||||
|
let (dir, leaf) = Self::split_path(&path);
|
||||||
|
let name = leaf.ok_or(StatusCode::PermissionDenied)?;
|
||||||
|
let Some(parent_id) = self.resolve_dir(&dir).await? else {
|
||||||
|
// A new space is made in the browser, not by mounting the root
|
||||||
|
// and mkdir-ing into it — the root has no `has_board` to ask about.
|
||||||
|
return Err(StatusCode::PermissionDenied);
|
||||||
|
};
|
||||||
|
let slug = nodes::unique_slug(&self.state.db, Some(parent_id), &nodes::slugify(name), None)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
let now = crate::now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at)
|
||||||
|
VALUES (?1, 'folder', ?2, ?3, ?4, ?4)",
|
||||||
|
)
|
||||||
|
.bind(parent_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
self.state.backup.signal();
|
||||||
|
Ok(ok_status(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rmdir(&mut self, id: u32, path: String) -> SftpResult<russh_sftp::protocol::Status> {
|
||||||
|
let entry = self.entry_at(&path).await?;
|
||||||
|
if entry.kind == nodes::DOCUMENT {
|
||||||
|
return Err(StatusCode::PermissionDenied);
|
||||||
|
}
|
||||||
|
let children = self.children(Some(entry.id)).await?;
|
||||||
|
if !children.is_empty() {
|
||||||
|
return Err(StatusCode::Failure);
|
||||||
|
}
|
||||||
|
sqlx::query("DELETE FROM nodes WHERE id = ?1")
|
||||||
|
.bind(entry.id)
|
||||||
|
.execute(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
self.state.backup.signal();
|
||||||
|
Ok(ok_status(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename(
|
||||||
|
&mut self,
|
||||||
|
id: u32,
|
||||||
|
oldpath: String,
|
||||||
|
newpath: String,
|
||||||
|
) -> SftpResult<russh_sftp::protocol::Status> {
|
||||||
|
let entry = self.entry_at(&oldpath).await?;
|
||||||
|
let (new_dir, new_leaf) = Self::split_path(&newpath);
|
||||||
|
let new_name = match entry.kind.as_str() {
|
||||||
|
k if k == nodes::DOCUMENT => new_leaf
|
||||||
|
.and_then(doc_slug)
|
||||||
|
.ok_or(StatusCode::PermissionDenied)?,
|
||||||
|
_ => new_leaf.ok_or(StatusCode::PermissionDenied)?,
|
||||||
|
};
|
||||||
|
let new_parent = self.resolve_dir(&new_dir).await?;
|
||||||
|
let Some(new_parent) = new_parent else {
|
||||||
|
return Err(StatusCode::PermissionDenied);
|
||||||
|
};
|
||||||
|
let slug = nodes::unique_slug(
|
||||||
|
&self.state.db,
|
||||||
|
Some(new_parent),
|
||||||
|
&nodes::slugify(new_name),
|
||||||
|
Some(entry.id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE nodes SET parent_id = ?1, name = ?2, slug = ?3, updated_at = ?4 WHERE id = ?5",
|
||||||
|
)
|
||||||
|
.bind(new_parent)
|
||||||
|
.bind(new_name)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(crate::now())
|
||||||
|
.bind(entry.id)
|
||||||
|
.execute(&self.state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
|
||||||
|
self.state.backup.signal();
|
||||||
|
Ok(ok_status(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok_status(id: u32) -> russh_sftp::protocol::Status {
|
||||||
|
russh_sftp::protocol::Status {
|
||||||
|
id,
|
||||||
|
status_code: StatusCode::Ok,
|
||||||
|
error_message: "Ok".to_string(),
|
||||||
|
language_tag: "en-US".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
//! Spaces and folders, mounted over SFTP instead of the browser. A separate
|
||||||
|
//! authentication path on purpose: the cookie session is for one browser tab
|
||||||
|
//! at a time, and a mount is a standing thing a laptop keeps open for weeks.
|
||||||
|
//! Trust here comes from a public key registered in `ssh_keys`, not a login.
|
||||||
|
|
||||||
|
mod fs;
|
||||||
|
|
||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use russh::{
|
||||||
|
keys::{Algorithm, PrivateKey, PublicKey},
|
||||||
|
server::{
|
||||||
|
Auth, ChannelOpenHandle, Config as ServerConfig, Handler as ServerHandler, Msg,
|
||||||
|
Server as _, Session,
|
||||||
|
},
|
||||||
|
Channel, ChannelId,
|
||||||
|
};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Normalise an `authorized_keys` line to `algorithm base64` (no comment) and
|
||||||
|
/// its SHA-256 fingerprint, the two things stored for a registered key.
|
||||||
|
pub fn fingerprint_of(line: &str) -> Result<(String, String)> {
|
||||||
|
let key = PublicKey::from_openssh(line.trim())?;
|
||||||
|
let normalized = key.to_openssh()?;
|
||||||
|
let fingerprint = key.fingerprint(russh::keys::HashAlg::Sha256).to_string();
|
||||||
|
Ok((normalized, fingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the host key from disk, generating and persisting one on first boot.
|
||||||
|
/// Losing this file only costs every client a host-key warning, never data,
|
||||||
|
/// so it lives next to the database rather than anywhere more precious.
|
||||||
|
fn load_or_create_host_key(path: &std::path::Path) -> Result<PrivateKey> {
|
||||||
|
if path.exists() {
|
||||||
|
return PrivateKey::read_openssh_file(path)
|
||||||
|
.with_context(|| format!("could not read {}", path.display()));
|
||||||
|
}
|
||||||
|
let key = PrivateKey::random(&mut rand10::rng(), Algorithm::Ed25519)?;
|
||||||
|
key.write_openssh_file(path, russh::keys::ssh_key::LineEnding::LF)
|
||||||
|
.with_context(|| format!("could not write {}", path.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(state: AppState, bind: SocketAddr) -> Result<()> {
|
||||||
|
let host_key = load_or_create_host_key(&state.config.sftp_host_key_path())?;
|
||||||
|
|
||||||
|
let config = Arc::new(ServerConfig {
|
||||||
|
keys: vec![host_key],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut server = SftpServer { state };
|
||||||
|
tracing::info!("sftp mount listening on {bind}");
|
||||||
|
server.run_on_address(config, bind).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SftpServer {
|
||||||
|
state: AppState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl russh::server::Server for SftpServer {
|
||||||
|
type Handler = SshSession;
|
||||||
|
|
||||||
|
fn new_client(&mut self, _addr: Option<SocketAddr>) -> Self::Handler {
|
||||||
|
SshSession {
|
||||||
|
state: self.state.clone(),
|
||||||
|
channel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SshSession {
|
||||||
|
state: AppState,
|
||||||
|
/// The one channel a mount opens for its sftp subsystem. A second
|
||||||
|
/// `channel_open_session` would mean a shell or a second subsystem,
|
||||||
|
/// neither of which this server offers.
|
||||||
|
channel: Option<Channel<Msg>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerHandler for SshSession {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
async fn auth_publickey(
|
||||||
|
&mut self,
|
||||||
|
_user: &str,
|
||||||
|
public_key: &PublicKey,
|
||||||
|
) -> Result<Auth, Self::Error> {
|
||||||
|
let fingerprint = public_key.fingerprint(russh::keys::HashAlg::Sha256).to_string();
|
||||||
|
let matched = sqlx::query("SELECT id FROM ssh_keys WHERE fingerprint = ?1")
|
||||||
|
.bind(&fingerprint)
|
||||||
|
.fetch_optional(&self.state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match matched {
|
||||||
|
Some(row) => {
|
||||||
|
let id: i64 = row.get("id");
|
||||||
|
sqlx::query("UPDATE ssh_keys SET last_used_at = ?1 WHERE id = ?2")
|
||||||
|
.bind(crate::now())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Auth::Accept)
|
||||||
|
}
|
||||||
|
None => Ok(Auth::reject()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_session(
|
||||||
|
&mut self,
|
||||||
|
channel: Channel<Msg>,
|
||||||
|
reply: ChannelOpenHandle,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.channel = Some(channel);
|
||||||
|
reply.accept().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subsystem_request(
|
||||||
|
&mut self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
name: &str,
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
if name != "sftp" {
|
||||||
|
session.channel_failure(channel_id)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(channel) = self.channel.take() else {
|
||||||
|
session.channel_failure(channel_id)?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session.channel_success(channel_id)?;
|
||||||
|
let handler = fs::SpaceFs::new(self.state.clone());
|
||||||
|
tokio::spawn(russh_sftp::server::run(channel.into_stream(), handler));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,15 @@ export interface WorkspaceNode extends NodeSummary {
|
|||||||
path: Crumb[]
|
path: Crumb[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A public key allowed to mount the workspace over SFTP. */
|
||||||
|
export interface SshKey {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
fingerprint: string
|
||||||
|
created_at: number
|
||||||
|
last_used_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface SharedDocument {
|
export interface SharedDocument {
|
||||||
name: string
|
name: string
|
||||||
body: string
|
body: string
|
||||||
@@ -248,4 +257,11 @@ export const api = {
|
|||||||
|
|
||||||
readShared: (token: string, key: string) =>
|
readShared: (token: string, key: string) =>
|
||||||
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
||||||
|
|
||||||
|
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
|
||||||
|
|
||||||
|
addSshKey: (name: string, public_key: string) =>
|
||||||
|
request<SshKey>('/ssh-keys', { method: 'POST', body: body({ name, public_key }) }),
|
||||||
|
|
||||||
|
removeSshKey: (id: number) => request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
@@ -703,6 +703,36 @@ export const commands: CommandSpec[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sshkey',
|
||||||
|
args: '[add <name> <key> | rm <name>]',
|
||||||
|
group: 'view',
|
||||||
|
help: 'list, add or remove keys allowed to mount the workspace over sftp',
|
||||||
|
run: (arg) =>
|
||||||
|
workspace.guard(async () => {
|
||||||
|
const trimmed = arg.trim()
|
||||||
|
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
|
||||||
|
const keys = await api.listSshKeys()
|
||||||
|
if (keys.length === 0) return workspace.say('no keys registered — :sshkey add <name> <key>')
|
||||||
|
return workspace.say(keys.map((k) => `${k.name} ${k.fingerprint}`).join(' · '))
|
||||||
|
}
|
||||||
|
const addMatch = /^add\s+(\S+)\s+(.+)$/.exec(trimmed)
|
||||||
|
if (addMatch) {
|
||||||
|
const [, name, key] = addMatch
|
||||||
|
const added = await api.addSshKey(name, key)
|
||||||
|
return workspace.say(`added ${added.name} (${added.fingerprint})`)
|
||||||
|
}
|
||||||
|
const rmMatch = /^rm\s+(\S+)$/.exec(trimmed)
|
||||||
|
if (rmMatch) {
|
||||||
|
const keys = await api.listSshKeys()
|
||||||
|
const target = keys.find((k) => k.name === rmMatch[1])
|
||||||
|
if (!target) return workspace.say(`no key named ${rmMatch[1]}`, 'error')
|
||||||
|
await api.removeSshKey(target.id)
|
||||||
|
return workspace.say(`removed ${target.name}`)
|
||||||
|
}
|
||||||
|
workspace.say('usage: :sshkey [add <name> <key> | rm <name>]', 'error')
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'help',
|
name: 'help',
|
||||||
aliases: ['h'],
|
aliases: ['h'],
|
||||||
|
|||||||
Reference in new issue
Block a user