mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
:backup login <username> shows Proton's third-party disclosure (and a warning when no session key file seals the stored session), asks for the password, then for a two-factor code when the account has one. If Proton answers with a human check, its own verify.proton.me page opens in a sheet; the token it posts back resumes the login. :backup logout forgets the session and ends it on Proton's side where Proton answers. The mirror picks up a new session within a minute, without a restart. The login is split into steps in backup::proton (start, second_factor, verified), and a half-done login waits in memory for five minutes at most. Every step goes through its own throttle, so the workspace cannot hammer Proton into locking the account. FIDO2-only accounts are told to add an authenticator app. The CLI runs over the same steps and points to :backup login when Proton wants a human check. The CSP lets verify.proton.me into a frame and nothing else.
225 lines
8.2 KiB
Rust
225 lines
8.2 KiB
Rust
pub mod backup;
|
|
pub mod boards;
|
|
pub mod export;
|
|
pub mod import;
|
|
pub mod inspect;
|
|
pub mod manifest;
|
|
pub mod media;
|
|
pub mod nodes;
|
|
pub mod seal;
|
|
pub mod session;
|
|
pub mod share;
|
|
pub mod ssh_keys;
|
|
pub mod vault;
|
|
|
|
use axum::{
|
|
extract::DefaultBodyLimit,
|
|
routing::{delete, get, post, put},
|
|
Router,
|
|
};
|
|
|
|
use crate::state::AppState;
|
|
|
|
/// Anything that is not an upload: a document body, a card, a password.
|
|
/// The size of the largest document anyone would write by hand, many times
|
|
/// over, and the same ceiling the SFTP mount puts on one.
|
|
const JSON_LIMIT: usize = 16 * 1024 * 1024;
|
|
|
|
/// The only body read before anyone has proved who they are.
|
|
const LOGIN_LIMIT: usize = 16 * 1024;
|
|
|
|
/// `max_upload` applies to the routes that take files and to nothing else, so
|
|
/// the upload ceiling is not also how much a stranger may make the server read.
|
|
pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|
// Multipart framing on top of the files themselves.
|
|
let uploads = DefaultBodyLimit::max(max_upload.saturating_add(1024 * 1024));
|
|
Router::new()
|
|
.route("/health", get(session::health))
|
|
.route("/me", get(session::me))
|
|
.route(
|
|
"/login",
|
|
post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)),
|
|
)
|
|
.route("/logout", post(session::logout))
|
|
.route("/sessions", get(session::list))
|
|
.route("/sessions/{id}", delete(session::revoke))
|
|
.route("/export", get(export::export))
|
|
.route("/backup", get(backup::status).post(backup::run))
|
|
.route("/backup/login", post(backup::login).delete(backup::logout))
|
|
.route("/backup/login/totp", post(backup::totp))
|
|
.route("/backup/login/captcha", post(backup::captcha))
|
|
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
|
.route("/spaces/overview", get(nodes::overview))
|
|
.route("/agenda", get(boards::agenda))
|
|
.route("/spaces/{id}/board", get(boards::board))
|
|
.route("/spaces/{id}/lists", post(boards::create_list))
|
|
.route(
|
|
"/lists/{id}",
|
|
put(boards::rename_list).delete(boards::remove_list),
|
|
)
|
|
.route("/lists/{id}/cards", post(boards::create_card))
|
|
.route(
|
|
"/cards/{id}",
|
|
put(boards::update_card).delete(boards::remove_card),
|
|
)
|
|
.route("/cards/{id}/move", post(boards::move_card))
|
|
.route("/nodes", get(nodes::list).post(nodes::create))
|
|
.route("/resolve", get(nodes::resolve))
|
|
.route("/nodes/{id}", get(nodes::get))
|
|
.route("/nodes/{id}", put(nodes::update))
|
|
.route("/nodes/{id}", delete(nodes::remove))
|
|
.route("/nodes/move", post(nodes::move_many))
|
|
.route("/nodes/{id}/move", post(nodes::move_node))
|
|
.route("/import", post(import::import_root).layer(uploads))
|
|
.route("/nodes/{id}/import", post(import::import).layer(uploads))
|
|
.route("/nodes/{id}/files", post(media::upload_into).layer(uploads))
|
|
.route("/nodes/{id}/encrypt", post(seal::encrypt))
|
|
.route("/nodes/{id}/decrypt", post(seal::decrypt))
|
|
.route("/nodes/{id}/inspect", post(inspect::inspect))
|
|
.route(
|
|
"/vault",
|
|
get(vault::status)
|
|
.post(vault::setup)
|
|
.put(vault::change_password),
|
|
)
|
|
.route("/vault/unlock", post(vault::unlock))
|
|
.route("/vault/lock", post(vault::lock))
|
|
.route("/nodes/{id}/share", post(share::enable))
|
|
.route("/nodes/{id}/share", delete(share::disable))
|
|
.route(
|
|
"/media",
|
|
post(media::upload).layer(uploads).get(media::list),
|
|
)
|
|
.route("/media/{id}", get(media::serve).delete(media::remove))
|
|
.route("/share/{token}", post(share::read))
|
|
.route("/share/{token}/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).patch(ssh_keys::update),
|
|
)
|
|
.layer(DefaultBodyLimit::max(JSON_LIMIT))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use std::sync::{atomic::AtomicI64, Arc};
|
|
|
|
use axum::{body::Body, http::Request};
|
|
use regex::Regex;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::{backup::Backup, config::Config, notify::Notifier, state::AppState, vault::Vault};
|
|
|
|
/// The routes that answer someone who has not logged in: to log in, to
|
|
/// say the server is up, and the share links, which carry their own key.
|
|
const PUBLIC: &[&str] = &[
|
|
"/health",
|
|
"/login",
|
|
"/logout",
|
|
"/share/{token}",
|
|
"/share/{token}/media/{id}",
|
|
];
|
|
|
|
async fn state() -> AppState {
|
|
state_in(".".into()).await
|
|
}
|
|
|
|
/// A whole `AppState` on an in-memory database, with its uploads under
|
|
/// `data_dir`.
|
|
pub(crate) async fn state_in(data_dir: std::path::PathBuf) -> AppState {
|
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.unwrap();
|
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
|
let config = Arc::new(Config {
|
|
data_dir,
|
|
..Config::for_tests(30)
|
|
});
|
|
AppState {
|
|
vault: Vault::new(db.clone(), config.vault_idle),
|
|
backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()),
|
|
notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(),
|
|
login_throttle: Arc::default(),
|
|
vault_throttle: Arc::default(),
|
|
proton_throttle: Arc::default(),
|
|
epoch: Arc::new(AtomicI64::new(0)),
|
|
sessions: Arc::default(),
|
|
config,
|
|
db,
|
|
}
|
|
}
|
|
|
|
/// Every route this file registers, read from the file itself, so a
|
|
/// route added later is covered without anyone remembering to.
|
|
fn routes() -> Vec<(String, String)> {
|
|
let path = Regex::new(r#"^\s*"([^"]+)""#).unwrap();
|
|
let method = Regex::new(r"\b(get|post|put|delete)\(").unwrap();
|
|
let source = include_str!("mod.rs");
|
|
let source = &source[..source.find("#[cfg(test)]").unwrap()];
|
|
let mut found = Vec::new();
|
|
// `.route(...)` up to its matching bracket, however rustfmt wrapped it.
|
|
for (at, _) in source.match_indices(".route(") {
|
|
let rest = &source[at + ".route(".len()..];
|
|
let mut depth = 1;
|
|
let close = rest
|
|
.char_indices()
|
|
.find(|&(_, c)| {
|
|
depth += match c {
|
|
'(' => 1,
|
|
')' => -1,
|
|
_ => 0,
|
|
};
|
|
depth == 0
|
|
})
|
|
.map(|(i, _)| i)
|
|
.unwrap();
|
|
let call = &rest[..close];
|
|
let Some(caps) = path.captures(call) else {
|
|
continue;
|
|
};
|
|
for m in method.captures_iter(call) {
|
|
found.push((m[1].to_uppercase(), caps[1].to_string()));
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn every_route_but_the_public_ones_wants_a_session() {
|
|
let routes = routes();
|
|
assert!(
|
|
routes.len() > 40,
|
|
"the route table was not read: {routes:?}"
|
|
);
|
|
let app = super::api_router(1024 * 1024).with_state(state().await);
|
|
|
|
for (method, path) in routes {
|
|
let uri = path.replace("{id}", "1").replace("{token}", "t");
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method.as_str())
|
|
.uri(&uri)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status().as_u16();
|
|
if PUBLIC.contains(&path.as_str()) {
|
|
assert_ne!(status, 401, "{method} {path} is public and answered 401");
|
|
} else {
|
|
assert_eq!(
|
|
status, 401,
|
|
"{method} {path} answered {status} without a session"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|