Archify Sync server, Dockerfile and Helm chart

Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
This commit is contained in:
root-at-skic committed 2026-11-06 12:06:59 +02:00
commit 5c43b90ef8
67 files changed
+8362

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
[package]
name = "archify-sync-server"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "1.0.104"
archify-sync-protocol = { version = "0.1.0", path = "../protocol" }
archify-sync-store = { version = "0.1.0", path = "../store" }
argon2 = "0.6.0"
axum = { version = "0.8.9", features = ["ws", "macros"] }
base64 = "0.23.1"
bytes = "1.12.1"
chrono = { version = "0.4.45", features = ["serde"] }
futures-util = "0.3.34"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
rand = "0.10.3"
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "json", "form", "query"] }
rustls = { version = "0.23.46", default-features = false, features = ["ring", "std"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.11.1"
thiserror = "2.0.21"
tokio = { version = "1.54.1", features = ["full"] }
tower-http = { version = "0.7.1", features = ["trace", "limit"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
url = "2.5.8"
uuid = { version = "1.29.0", features = ["serde", "v7"] }
yrs = { version = "0.28.1", features = ["sync"] }
+13
View File
@@ -0,0 +1,13 @@
$schema: 'https://moonrepo.dev/schemas/project.json'
language: rust
tasks:
check:
command: cargo clippy -p archify-sync-server --all-targets -- -D warnings
inputs: ['src/**/*', 'Cargo.toml']
test:
command: cargo test -p archify-sync-server
inputs: ['src/**/*', 'Cargo.toml']
format:
command: cargo fmt -p archify-sync-server --check
inputs: ['src/**/*']
dependsOn: [protocol, store]
+430
View File
@@ -0,0 +1,430 @@
//! The JSON API under `/v1` and the WebSocket.
use archify_sync_protocol::{
CreateInviteRequest, CreateSpaceRequest, Invite, JoinResponse, LogoutRequest, Member,
PROTOCOL_VERSION, PublishRequest, ServerInfo, SetupRequest, Space, SpaceRole, TokenRequest,
TokenResponse, Version, VersionFile, VersionFiles, VersionKind,
};
use archify_sync_store::{InviteOutcome, MemberRow, SpaceRow, VersionRow};
use axum::Json;
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::Response;
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::{self, Authed};
use crate::doc;
use crate::error::{ApiErr, ApiResult};
use crate::state::AppState;
pub async fn info(State(state): State<AppState>) -> ApiResult<Json<ServerInfo>> {
Ok(Json(ServerInfo {
name: state.config.server_name.clone(),
version: env!("CARGO_PKG_VERSION").into(),
protocol: PROTOCOL_VERSION,
set_up: state.first_start.lock().await.is_none(),
}))
}
pub async fn healthz() -> &'static str {
"ok"
}
pub async fn readyz(State(state): State<AppState>) -> ApiResult<&'static str> {
state.store.ping().await?;
Ok("ok")
}
// ---------------------------------------------------------------- accounts
/// Creates the first admin with the first-start code. Shared with the setup page.
pub async fn setup_admin(
state: &AppState,
req: &SetupRequest,
) -> ApiResult<archify_sync_store::AccountRow> {
let mut first_start = state.first_start.lock().await;
let Some(code) = first_start.as_ref() else {
return Err(ApiErr::conflict("This server is set up already."));
};
if !auth::same_secret(&req.code.trim().to_ascii_uppercase(), code) {
return Err(ApiErr::unauthorized(
"The first-start code does not match. It is in the server's log.",
));
}
auth::check_name(&req.name)?;
auth::check_new_password(&req.password)?;
if req.organisation.trim().is_empty() {
return Err(ApiErr::invalid("Name the organisation."));
}
let hash = auth::hash_password(&req.password)?;
let display = req
.display_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(&req.name);
let account = state
.store
.create_first_admin(req.organisation.trim(), &req.name, display, &hash)
.await?
.ok_or_else(|| ApiErr::conflict("This server is set up already."))?;
*first_start = None;
tracing::info!(account = %account.id, "first admin set up");
Ok(account)
}
pub async fn setup(
State(state): State<AppState>,
Json(req): Json<SetupRequest>,
) -> ApiResult<Json<TokenResponse>> {
let account = setup_admin(&state, &req).await?;
Ok(Json(auth::issue_tokens(&state, &account, None).await?))
}
pub async fn token(
State(state): State<AppState>,
Json(req): Json<TokenRequest>,
) -> ApiResult<Json<TokenResponse>> {
match req {
TokenRequest::AuthorizationCode {
code,
code_verifier,
redirect_uri,
} => {
let account_id = state
.codes
.take(&code, &code_verifier, &redirect_uri)
.await
.ok_or_else(|| {
ApiErr::unauthorized("The sign-in code is not valid. Sign in again.")
})?;
let account = state
.store
.account(account_id)
.await?
.ok_or_else(|| ApiErr::unauthorized("Unknown account."))?;
Ok(Json(auth::issue_tokens(&state, &account, None).await?))
}
TokenRequest::RefreshToken { refresh_token } => {
Ok(Json(auth::refresh(&state, &refresh_token).await?))
}
}
}
pub async fn logout(
State(state): State<AppState>,
Json(req): Json<LogoutRequest>,
) -> ApiResult<StatusCode> {
state
.store
.revoke_refresh(&auth::sha256(&req.refresh_token))
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn me(
State(state): State<AppState>,
authed: Authed,
) -> ApiResult<Json<archify_sync_protocol::Account>> {
let account = state
.store
.account(authed.account)
.await?
.ok_or_else(|| ApiErr::unauthorized("Unknown account."))?;
Ok(Json(auth::account_dto(&account)))
}
// ------------------------------------------------------------------ spaces
fn space_dto(row: SpaceRow) -> Space {
Space {
id: row.id,
name: row.name,
role: role(&row.role),
created_at: row.created_at,
}
}
fn role(name: &str) -> SpaceRole {
if name == "owner" {
SpaceRole::Owner
} else {
SpaceRole::Editor
}
}
fn member_dto(row: MemberRow) -> Member {
Member {
account_id: row.account_id,
name: row.name,
display_name: row.display_name,
role: role(&row.role),
}
}
fn version_dto(row: VersionRow) -> Version {
Version {
id: row.id,
major: row.major,
minor: row.minor,
kind: match row.kind.as_str() {
"publish" => VersionKind::Publish,
"restore" => VersionKind::Restore,
_ => VersionKind::Snapshot,
},
name: row.name,
created_by: row.created_by,
created_at: row.created_at,
restored_from: row.restored_from,
}
}
/// The space as the caller sees it; not found when the caller is no member.
async fn member_space(state: &AppState, authed: &Authed, space: Uuid) -> ApiResult<SpaceRow> {
state
.store
.space_for(space, authed.account)
.await?
.ok_or_else(|| ApiErr::not_found("No such space, or you are not a member."))
}
pub async fn list_spaces(
State(state): State<AppState>,
authed: Authed,
) -> ApiResult<Json<Vec<Space>>> {
let rows = state.store.spaces_of(authed.account).await?;
Ok(Json(rows.into_iter().map(space_dto).collect()))
}
pub async fn create_space(
State(state): State<AppState>,
authed: Authed,
Json(req): Json<CreateSpaceRequest>,
) -> ApiResult<(StatusCode, Json<Space>)> {
let name = req.name.trim();
if name.is_empty() || name.len() > 200 {
return Err(ApiErr::invalid("A space name has 1 to 200 characters."));
}
let row = state
.store
.create_space(authed.organisation, name, authed.account)
.await?;
Ok((StatusCode::CREATED, Json(space_dto(row))))
}
pub async fn get_space(
State(state): State<AppState>,
authed: Authed,
Path(space): Path<Uuid>,
) -> ApiResult<Json<Space>> {
Ok(Json(space_dto(member_space(&state, &authed, space).await?)))
}
pub async fn members(
State(state): State<AppState>,
authed: Authed,
Path(space): Path<Uuid>,
) -> ApiResult<Json<Vec<Member>>> {
member_space(&state, &authed, space).await?;
Ok(Json(
state
.store
.members(space)
.await?
.into_iter()
.map(member_dto)
.collect(),
))
}
pub async fn remove_member(
State(state): State<AppState>,
authed: Authed,
Path((space, account)): Path<(Uuid, Uuid)>,
) -> ApiResult<StatusCode> {
let row = member_space(&state, &authed, space).await?;
// Anyone may leave; only owners and admins remove others.
if account != authed.account && row.role != "owner" && !authed.admin {
return Err(ApiErr::forbidden(
"Only the owner or an admin removes members.",
));
}
if state.store.remove_member(space, account).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiErr::not_found("No such member, or it is the owner."))
}
}
pub async fn create_invite(
State(state): State<AppState>,
authed: Authed,
Path(space): Path<Uuid>,
Json(req): Json<CreateInviteRequest>,
) -> ApiResult<(StatusCode, Json<Invite>)> {
let row = member_space(&state, &authed, space).await?;
if row.role != "owner" && !authed.admin && !state.config.members_can_invite {
return Err(ApiErr::forbidden(
"Only the owner or an admin invites people to this space.",
));
}
let hours = req
.expires_in_hours
.unwrap_or(state.config.invite_hours)
.clamp(1, 24 * 90);
let expires_at = chrono::Utc::now() + chrono::Duration::hours(i64::from(hours));
let max_uses = req.max_uses.map(|n| n.clamp(1, 10_000) as i32);
let token = auth::random_token();
state
.store
.create_invite(
&auth::sha256(&token),
space,
authed.account,
expires_at,
max_uses,
)
.await?;
let url = format!("{}/join/{token}", state.config.public_url);
Ok((
StatusCode::CREATED,
Json(Invite {
token,
url,
expires_at,
}),
))
}
pub async fn accept_invite(
State(state): State<AppState>,
authed: Authed,
Path(token): Path<String>,
) -> ApiResult<Json<JoinResponse>> {
let account = state
.store
.account(authed.account)
.await?
.ok_or_else(|| ApiErr::unauthorized("Unknown account."))?;
match state
.store
.accept_invite(&auth::sha256(&token), &account)
.await?
{
InviteOutcome::Joined { space_id } => {
let row = member_space(&state, &authed, space_id).await?;
Ok(Json(JoinResponse {
space: space_dto(row),
}))
}
InviteOutcome::Invalid => Err(ApiErr::not_found(
"This invite link has expired or was used up.",
)),
}
}
// ---------------------------------------------------------------- versions
#[derive(Deserialize)]
pub struct Limit {
limit: Option<i64>,
}
pub async fn versions(
State(state): State<AppState>,
authed: Authed,
Path(space): Path<Uuid>,
Query(q): Query<Limit>,
) -> ApiResult<Json<Vec<Version>>> {
member_space(&state, &authed, space).await?;
let rows = state
.store
.versions(space, q.limit.unwrap_or(100).clamp(1, 1000))
.await?;
Ok(Json(rows.into_iter().map(version_dto).collect()))
}
pub async fn publish(
State(state): State<AppState>,
authed: Authed,
Path(space): Path<Uuid>,
Json(req): Json<PublishRequest>,
) -> ApiResult<(StatusCode, Json<Version>)> {
member_space(&state, &authed, space).await?;
let name = req.name.trim();
if name.is_empty() || name.len() > 200 {
return Err(ApiErr::invalid("Name the version (1 to 200 characters)."));
}
let row = state
.hub
.publish(space, name, authed.account)
.await
.map_err(ApiErr::internal)?;
Ok((StatusCode::CREATED, Json(version_dto(row))))
}
pub async fn version_files(
State(state): State<AppState>,
authed: Authed,
Path((space, version)): Path<(Uuid, i64)>,
) -> ApiResult<Json<VersionFiles>> {
member_space(&state, &authed, space).await?;
let (row, encoded) = state
.store
.version(space, version)
.await?
.ok_or_else(|| ApiErr::not_found("No such version."))?;
let files = doc::files(&doc::from_state(&encoded).map_err(ApiErr::internal)?)
.into_iter()
.map(|(path, text)| VersionFile { path, text })
.collect();
Ok(Json(VersionFiles {
version: version_dto(row),
files,
}))
}
pub async fn restore(
State(state): State<AppState>,
authed: Authed,
Path((space, version)): Path<(Uuid, i64)>,
) -> ApiResult<(StatusCode, Json<Version>)> {
member_space(&state, &authed, space).await?;
let row = state
.hub
.restore(space, version, authed.account)
.await
.map_err(ApiErr::internal)?
.ok_or_else(|| ApiErr::not_found("No such version."))?;
Ok((StatusCode::CREATED, Json(version_dto(row))))
}
// ----------------------------------------------------------------- live
#[derive(Deserialize)]
pub struct SyncQuery {
v: Option<u32>,
}
/// `GET /v1/spaces/{id}/sync`: the y-websocket protocol for one space.
pub async fn sync(
State(state): State<AppState>,
authed: Authed,
Path(space): Path<Uuid>,
Query(q): Query<SyncQuery>,
ws: WebSocketUpgrade,
) -> ApiResult<Response> {
if q.v.unwrap_or(PROTOCOL_VERSION) != PROTOCOL_VERSION {
return Err(ApiErr::new(
StatusCode::UPGRADE_REQUIRED,
"protocol",
format!("This server speaks protocol {PROTOCOL_VERSION}. Update the extension."),
));
}
member_space(&state, &authed, space).await?;
let hub = state.hub.clone();
Ok(ws
.max_message_size(state.config.max_message_bytes)
.on_upgrade(move |socket| hub.serve(socket, space, authed.account)))
}
+327
View File
@@ -0,0 +1,327 @@
//! Passwords, tokens and sign-in codes.
//!
//! Access tokens are short JWTs signed with a key kept in the database, so
//! every replica accepts them. Refresh tokens are random, stored as hashes,
//! single use, and grouped in a family: presenting a used one signs out the
//! whole session. The browser sign-in hands a one-time code to the editor's
//! URI handler; the editor exchanges it with its PKCE verifier. Repeated
//! wrong passwords for one name lock that name out for a while.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use archify_sync_protocol::{Account, OrgRole, TokenResponse};
use archify_sync_store::{AccountRow, RefreshOutcome};
use argon2::Argon2;
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
use axum::extract::FromRequestParts;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::error::{ApiErr, ApiResult};
use crate::metrics::{METRICS, Metrics};
use crate::state::AppState;
const ISSUER: &str = "archify-sync";
const CODE_TTL: Duration = Duration::from_secs(120);
pub fn hash_password(password: &str) -> ApiResult<String> {
Argon2::default()
.hash_password(password.as_bytes())
.map(|h| h.to_string())
.map_err(ApiErr::internal)
}
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash).is_ok_and(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
})
}
/// Checks the rules for a new password; the message is shown to the person.
pub fn check_new_password(password: &str) -> ApiResult<()> {
if password.chars().count() < 10 {
return Err(ApiErr::invalid(
"The password needs at least 10 characters.",
));
}
Ok(())
}
pub fn check_name(name: &str) -> ApiResult<()> {
let ok = (2..=64).contains(&name.len())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@'));
if ok {
Ok(())
} else {
Err(ApiErr::invalid(
"A sign-in name has 2 to 64 letters, digits or . _ - @.",
))
}
}
/// A random token for links and sessions, URL safe.
pub fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::fill(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn sha256(text: &str) -> Vec<u8> {
Sha256::digest(text.as_bytes()).to_vec()
}
/// Constant-time comparison of two secrets.
pub fn same_secret(a: &str, b: &str) -> bool {
let (a, b) = (sha256(a), sha256(b));
a.iter().zip(&b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// The code a new server prints, for its first admin: `ABCD-EFGH-JKLM`.
pub fn first_start_code() -> String {
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut bytes = [0u8; 12];
rand::fill(&mut bytes);
let chars: Vec<char> = bytes
.iter()
.map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
.collect();
chars
.chunks(4)
.map(|c| c.iter().collect::<String>())
.collect::<Vec<_>>()
.join("-")
}
pub fn account_dto(row: &AccountRow) -> Account {
Account {
id: row.id,
organisation_id: row.organisation_id,
name: row.name.clone(),
display_name: row.display_name.clone(),
role: if row.role == "admin" {
OrgRole::Admin
} else {
OrgRole::Member
},
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: Uuid,
org: Uuid,
role: String,
iss: String,
iat: u64,
exp: u64,
}
pub struct Keys {
encoding: EncodingKey,
decoding: DecodingKey,
}
impl Keys {
pub fn from_secret(secret: &[u8]) -> Self {
Self {
encoding: EncodingKey::from_secret(secret),
decoding: DecodingKey::from_secret(secret),
}
}
}
/// A request signed in with a valid access token.
#[derive(Debug, Clone)]
pub struct Authed {
pub account: Uuid,
pub organisation: Uuid,
pub admin: bool,
}
impl FromRequestParts<AppState> for Authed {
type Rejection = ApiErr;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| ApiErr::unauthorized("Sign in first."))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| ApiErr::unauthorized("Expected a bearer token."))?;
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[ISSUER]);
let data = jsonwebtoken::decode::<Claims>(token, &state.keys.decoding, &validation)
.map_err(|_| ApiErr::unauthorized("The sign-in has expired or is not valid."))?;
let c = data.claims;
Ok(Authed {
account: c.sub,
organisation: c.org,
admin: c.role == "admin",
})
}
}
/// A new access token and refresh token, in the given session family.
pub async fn issue_tokens(
state: &AppState,
account: &AccountRow,
family: Option<Uuid>,
) -> ApiResult<TokenResponse> {
let now = jsonwebtoken::get_current_timestamp();
let ttl = state.config.access_ttl.as_secs();
let claims = Claims {
sub: account.id,
org: account.organisation_id,
role: account.role.clone(),
iss: ISSUER.into(),
iat: now,
exp: now + ttl,
};
let access_token = jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&claims,
&state.keys.encoding,
)
.map_err(ApiErr::internal)?;
let refresh_token = random_token();
let expires_at = chrono::Utc::now() + state.config.refresh_ttl;
state
.store
.insert_refresh(
&sha256(&refresh_token),
family.unwrap_or_else(Uuid::now_v7),
account.id,
expires_at,
)
.await?;
Ok(TokenResponse {
access_token,
expires_in: ttl,
refresh_token,
account: account_dto(account),
})
}
pub async fn refresh(state: &AppState, refresh_token: &str) -> ApiResult<TokenResponse> {
match state
.store
.use_refresh(&sha256(refresh_token), state.config.refresh_grace_secs)
.await?
{
RefreshOutcome::Valid { account, family } => {
issue_tokens(state, &account, Some(family)).await
}
RefreshOutcome::Reused => {
Metrics::inc(&METRICS.refresh_reuse_total);
tracing::warn!("a used refresh token was presented again; its session is signed out");
Err(ApiErr::unauthorized(
"This session was signed out. Sign in again.",
))
}
RefreshOutcome::Invalid => Err(ApiErr::unauthorized(
"The session has expired. Sign in again.",
)),
}
}
/// Sign-in codes waiting for the editor to exchange them.
#[derive(Default)]
pub struct PendingCodes(Mutex<HashMap<String, PendingCode>>);
struct PendingCode {
account: Uuid,
challenge: String,
redirect_uri: String,
created: Instant,
}
impl PendingCodes {
pub async fn insert(&self, account: Uuid, challenge: String, redirect_uri: String) -> String {
let code = random_token();
let mut codes = self.0.lock().await;
codes.retain(|_, c| c.created.elapsed() < CODE_TTL);
codes.insert(
code.clone(),
PendingCode {
account,
challenge,
redirect_uri,
created: Instant::now(),
},
);
code
}
/// The account behind a code, if the code is fresh and the verifier and redirect match. One use only.
pub async fn take(&self, code: &str, verifier: &str, redirect_uri: &str) -> Option<Uuid> {
let pending = self.0.lock().await.remove(code)?;
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
(pending.created.elapsed() < CODE_TTL
&& pending.redirect_uri == redirect_uri
&& same_secret(&challenge, &pending.challenge))
.then_some(pending.account)
}
}
/// Whether the browser may send a sign-in code to this URI: the extension's
/// handler in a known editor, or a loopback address.
pub fn redirect_allowed(state: &AppState, uri: &str) -> bool {
let Ok(url) = url::Url::parse(uri) else {
return false;
};
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
match url.scheme() {
"http" => host == "127.0.0.1" || host == "localhost" || host == "[::1]",
scheme => {
state.config.redirect_schemes.iter().any(|s| s == scheme)
&& host == state.config.extension_id
}
}
}
/// Wrong passwords per account name. After `SIGN_IN_FAILURES` within
/// `SIGN_IN_WINDOW`, the name is refused until the window has passed.
#[derive(Default)]
pub struct SignInLimits(Mutex<HashMap<String, (u32, Instant)>>);
const SIGN_IN_FAILURES: u32 = 10;
const SIGN_IN_WINDOW: Duration = Duration::from_secs(15 * 60);
impl SignInLimits {
pub async fn blocked(&self, name: &str) -> bool {
let mut limits = self.0.lock().await;
limits.retain(|_, (_, first)| first.elapsed() < SIGN_IN_WINDOW);
limits
.get(&name.to_lowercase())
.is_some_and(|(failures, _)| *failures >= SIGN_IN_FAILURES)
}
pub async fn failed(&self, name: &str) {
let mut limits = self.0.lock().await;
let entry = limits
.entry(name.to_lowercase())
.or_insert((0, Instant::now()));
entry.0 += 1;
}
pub async fn succeeded(&self, name: &str) {
self.0.lock().await.remove(&name.to_lowercase());
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Settings from the environment. Every variable starts with `ARCHIFY_SYNC_`.
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{Context, Result};
use crate::hub::SnapshotPolicy;
use crate::oidc::OidcConfig;
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub database_max_connections: u32,
pub listen: SocketAddr,
/// Where `/metrics` is served; off when unset.
pub metrics_listen: Option<SocketAddr>,
/// The address people reach the server at; invite links point here.
pub public_url: String,
pub server_name: String,
pub snapshots: SnapshotPolicy,
pub invite_hours: u32,
/// Whether editors may create invite links too, not only owners and admins.
pub members_can_invite: bool,
pub access_ttl: Duration,
pub refresh_ttl: Duration,
/// A refresh token used again within this many seconds is not treated as stolen.
pub refresh_grace_secs: u32,
/// URI schemes of the editors that may receive a sign-in code (`vscode`, `cursor`, …).
pub redirect_schemes: Vec<String>,
/// The extension's id, the authority of its callback URI.
pub extension_id: String,
pub max_message_bytes: usize,
/// Single sign-on, when `ARCHIFY_SYNC_OIDC_ISSUER` is set.
pub oidc: Option<OidcConfig>,
}
impl Config {
pub fn from_env() -> Result<Self> {
let listen: SocketAddr = var("LISTEN", "0.0.0.0:8080")
.parse()
.context("ARCHIFY_SYNC_LISTEN")?;
let public_url = var("PUBLIC_URL", &format!("http://localhost:{}", listen.port()));
Ok(Self {
database_url: std::env::var("ARCHIFY_SYNC_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.context("ARCHIFY_SYNC_DATABASE_URL is not set")?,
database_max_connections: number("DATABASE_MAX_CONNECTIONS", 10)?,
listen,
metrics_listen: std::env::var("ARCHIFY_SYNC_METRICS_LISTEN")
.ok()
.filter(|v| !v.is_empty())
.map(|v| v.parse())
.transpose()
.context("ARCHIFY_SYNC_METRICS_LISTEN")?,
public_url: public_url.trim_end_matches('/').to_owned(),
server_name: var("NAME", "Archify Sync"),
snapshots: SnapshotPolicy {
idle: Duration::from_secs(number("SNAPSHOT_IDLE_SECS", 30)?),
max_age: Duration::from_secs(number("SNAPSHOT_MAX_SECS", 600)?),
max_edits: number("SNAPSHOT_MAX_EDITS", 2000)?,
},
invite_hours: number("INVITE_HOURS", 168)?,
members_can_invite: var("MEMBERS_CAN_INVITE", "false") == "true",
access_ttl: Duration::from_secs(number("ACCESS_TTL_SECS", 900)?),
refresh_ttl: Duration::from_secs(number::<u64>("REFRESH_TTL_DAYS", 30)? * 86_400),
refresh_grace_secs: number("REFRESH_GRACE_SECS", 30)?,
redirect_schemes: var(
"REDIRECT_SCHEMES",
"vscode,vscode-insiders,vscodium,cursor,windsurf",
)
.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect(),
extension_id: var("EXTENSION_ID", "root-at-skic.archify-vscode-ext")
.to_ascii_lowercase(),
max_message_bytes: number("MAX_MESSAGE_BYTES", 8 * 1024 * 1024)?,
oidc: oidc_from_env()?,
})
}
}
fn list(name: &str) -> Vec<String> {
var(name, "")
.split(',')
.map(|s| s.trim().trim_start_matches('/').to_owned())
.filter(|s| !s.is_empty())
.collect()
}
fn oidc_from_env() -> Result<Option<OidcConfig>> {
let issuer = var("OIDC_ISSUER", "");
if issuer.is_empty() {
return Ok(None);
}
let parsed = url::Url::parse(&issuer).context("ARCHIFY_SYNC_OIDC_ISSUER")?;
let loopback = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
anyhow::ensure!(
parsed.scheme() == "https" || (parsed.scheme() == "http" && loopback),
"ARCHIFY_SYNC_OIDC_ISSUER must be https (http only on this machine)"
);
let client_id = var("OIDC_CLIENT_ID", "");
anyhow::ensure!(
!client_id.is_empty(),
"ARCHIFY_SYNC_OIDC_CLIENT_ID is not set"
);
// The secret from the environment, or from a mounted file.
let client_secret = match std::env::var("ARCHIFY_SYNC_OIDC_CLIENT_SECRET") {
Ok(secret) if !secret.is_empty() => Some(secret),
_ => match std::env::var("ARCHIFY_SYNC_OIDC_CLIENT_SECRET_FILE") {
Ok(path) if !path.is_empty() => Some(
std::fs::read_to_string(&path)
.with_context(|| format!("ARCHIFY_SYNC_OIDC_CLIENT_SECRET_FILE {path}"))?
.trim()
.to_owned(),
),
_ => None,
},
};
Ok(Some(OidcConfig {
issuer: issuer.trim_end_matches('/').to_owned(),
client_id,
client_secret,
label: var("OIDC_LABEL", "single sign-on"),
scopes: var("OIDC_SCOPES", "openid profile email"),
groups_claim: var("OIDC_GROUPS_CLAIM", "groups"),
allowed_groups: list("OIDC_ALLOWED_GROUPS"),
admin_groups: list("OIDC_ADMIN_GROUPS"),
}))
}
fn var(name: &str, default: &str) -> String {
std::env::var(format!("ARCHIFY_SYNC_{name}")).unwrap_or_else(|_| default.to_owned())
}
fn number<T: std::str::FromStr>(name: &str, default: T) -> Result<T>
where
T::Err: std::error::Error + Send + Sync + 'static,
{
match std::env::var(format!("ARCHIFY_SYNC_{name}")) {
Ok(v) => v.parse().with_context(|| format!("ARCHIFY_SYNC_{name}")),
Err(_) => Ok(default),
}
}
+160
View File
@@ -0,0 +1,160 @@
//! A space's Yjs document: one `Y.Text` per model file in the `files` map.
use std::collections::BTreeMap;
use archify_sync_protocol::FILES_MAP;
use archify_sync_store::StoredDoc;
use yrs::updates::decoder::Decode;
use yrs::{
Doc, GetString, Map, OffsetKind, Options, Out, ReadTxn, StateVector, Text, TextPrelim,
Transact, Update,
};
/// A document that counts text offsets in UTF-16, as Yjs in the extension does.
pub fn new_doc() -> Doc {
Doc::with_options(Options {
offset_kind: OffsetKind::Utf16,
..Options::default()
})
}
pub fn load(stored: &StoredDoc) -> anyhow::Result<Doc> {
let doc = new_doc();
{
let mut txn = doc.transact_mut();
if let Some(state) = &stored.state {
txn.apply_update(Update::decode_v1(state)?)?;
}
for update in &stored.updates {
txn.apply_update(Update::decode_v1(update)?)?;
}
}
Ok(doc)
}
pub fn from_state(state: &[u8]) -> anyhow::Result<Doc> {
let doc = new_doc();
doc.transact_mut().apply_update(Update::decode_v1(state)?)?;
Ok(doc)
}
pub fn encode_state(doc: &Doc) -> Vec<u8> {
doc.transact()
.encode_state_as_update_v1(&StateVector::default())
}
/// Path → text of every model file.
pub fn files(doc: &Doc) -> BTreeMap<String, String> {
let map = doc.get_or_insert_map(FILES_MAP);
let txn = doc.transact();
map.iter(&txn)
.filter_map(|(path, value)| match value {
Out::YText(text) => Some((path.to_owned(), text.get_string(&txn))),
_ => None,
})
.collect()
}
/// Makes `doc` hold the files of `target` as ordinary edits, so every copy
/// takes them like any other change. Returns the update, empty when nothing changed.
pub fn replace_files(doc: &Doc, target: &BTreeMap<String, String>) -> Vec<u8> {
let map = doc.get_or_insert_map(FILES_MAP);
let mut txn = doc.transact_mut();
let current: Vec<String> = map.keys(&txn).map(str::to_owned).collect();
for path in current.iter().filter(|p| !target.contains_key(*p)) {
map.remove(&mut txn, path);
}
for (path, text) in target {
match map.get(&txn, path) {
Some(Out::YText(existing)) => {
let old = existing.get_string(&txn);
if let Some((at, remove, insert)) = minimal_change(&old, text) {
if remove > 0 {
existing.remove_range(&mut txn, at, remove);
}
if !insert.is_empty() {
existing.insert(&mut txn, at, insert);
}
}
}
_ => {
map.insert(&mut txn, path.as_str(), TextPrelim::new(text.as_str()));
}
}
}
txn.encode_update_v1()
}
/// The one edit that turns `old` into `new`: UTF-16 offset, UTF-16 length to
/// remove, text to insert. Keeps the common start and end, as the extension's
/// `minimalChange` does, and never splits a character.
pub fn minimal_change<'a>(old: &str, new: &'a str) -> Option<(u32, u32, &'a str)> {
if old == new {
return None;
}
let prefix: usize = old
.chars()
.zip(new.chars())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum();
let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]);
let suffix: usize = old_rest
.chars()
.rev()
.zip(new_rest.chars().rev())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum();
let removed = &old_rest[..old_rest.len() - suffix];
let inserted = &new_rest[..new_rest.len() - suffix];
let utf16 = |s: &str| s.encode_utf16().count() as u32;
Some((utf16(&old[..prefix]), utf16(removed), inserted))
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn minimal_change_keeps_common_ends() {
assert_eq!(
minimal_change("label: API", "label: Gateway API"),
Some((7, 0, "Gateway "))
);
assert_eq!(minimal_change("abc", "abc"), None);
assert_eq!(minimal_change("a😀b", "a😀cb"), Some((3, 0, "c")));
assert_eq!(minimal_change("x😀y", "x😃y"), Some((1, 2, "😃")));
}
#[test]
fn replace_files_reaches_target_and_syncs() {
let doc = new_doc();
replace_files(
&doc,
&texts(&[("main.archify", "model: a\n"), ("old.archify", "x")]),
);
let other = from_state(&encode_state(&doc)).unwrap();
let target = texts(&[("main.archify", "model: b\n"), ("new.archify", "y")]);
let update = replace_files(&doc, &target);
assert_eq!(files(&doc), target);
other
.transact_mut()
.apply_update(Update::decode_v1(&update).unwrap())
.unwrap();
assert_eq!(files(&other), target);
assert!(
replace_files(&doc, &target).len() <= 2,
"no change gives an empty update"
);
}
}
+71
View File
@@ -0,0 +1,71 @@
use archify_sync_protocol::ApiError;
use archify_sync_store::StoreError;
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
/// An HTTP error with a stable code for clients.
#[derive(Debug)]
pub struct ApiErr {
pub status: StatusCode,
pub code: &'static str,
pub message: String,
}
pub type ApiResult<T> = Result<T, ApiErr>;
impl ApiErr {
pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
Self {
status,
code,
message: message.into(),
}
}
pub fn too_many(message: impl Into<String>) -> Self {
Self::new(StatusCode::TOO_MANY_REQUESTS, "too_many_attempts", message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, "forbidden", message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, "not_found", message)
}
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "invalid", message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, "conflict", message)
}
pub fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(%error, "request failed");
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"Something went wrong on the server.",
)
}
}
impl From<StoreError> for ApiErr {
fn from(error: StoreError) -> Self {
match error {
StoreError::Conflict(what) => Self::conflict(format!("This {what} already exists.")),
other => Self::internal(other),
}
}
}
impl IntoResponse for ApiErr {
fn into_response(self) -> Response {
let body = ApiError {
error: self.code.to_owned(),
message: self.message,
};
(self.status, Json(body)).into_response()
}
}
+466
View File
@@ -0,0 +1,466 @@
//! The live hub: one room per open space, holding its Yjs document and
//! awareness in memory. Clients speak the y-websocket protocol (sync step 1
//! and 2, updates, awareness). Every update is appended to PostgreSQL before
//! it is applied and passed on, under the room's lock, so a version's
//! `up_to` never drops an update the version does not hold.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use archify_sync_protocol::VersionKind;
use archify_sync_store::{Store, VersionRow};
use axum::extract::ws::{Message as WsMessage, WebSocket};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{Mutex, broadcast};
use uuid::Uuid;
use yrs::sync::{Awareness, Message, SyncMessage};
use yrs::updates::decoder::Decode;
use yrs::updates::encoder::Encode;
use yrs::{ClientID, ReadTxn, Transact, Update};
use crate::doc;
use crate::metrics::{METRICS, Metrics};
/// An encoded update with no changes in it.
const EMPTY_UPDATE: &[u8] = &[0, 0];
pub struct Hub {
store: Store,
rooms: Mutex<HashMap<Uuid, Arc<Room>>>,
snapshots: SnapshotPolicy,
next_conn: AtomicU64,
}
/// When a room with edits gets a snapshot, which also empties its update log.
#[derive(Debug, Clone, Copy)]
pub struct SnapshotPolicy {
/// After this long without edits.
pub idle: Duration,
/// After this long of editing without a pause, so a busy space's log stays short.
pub max_age: Duration,
/// After this many edits.
pub max_edits: u64,
}
pub struct Room {
pub space: Uuid,
state: Mutex<RoomState>,
/// Frames for every connection, tagged with the sender so it skips its own.
tx: broadcast::Sender<(u64, Bytes)>,
}
struct RoomState {
awareness: Awareness,
/// Users holding the room: connections and short API calls.
holders: usize,
last_update: i64,
latest: Option<VersionRow>,
/// Edits since the latest version.
dirty: Option<Dirty>,
}
#[derive(Debug, Clone, Copy)]
struct Dirty {
since: Instant,
last: Instant,
edits: u64,
}
impl RoomState {
fn edited(&mut self) {
let now = Instant::now();
let dirty = self.dirty.get_or_insert(Dirty {
since: now,
last: now,
edits: 0,
});
dirty.last = now;
dirty.edits += 1;
}
}
impl SnapshotPolicy {
fn due(&self, dirty: &Dirty) -> bool {
dirty.last.elapsed() >= self.idle
|| dirty.since.elapsed() >= self.max_age
|| dirty.edits >= self.max_edits
}
}
impl Hub {
pub fn new(store: Store, snapshots: SnapshotPolicy) -> Self {
Self {
store,
rooms: Mutex::new(HashMap::new()),
snapshots,
next_conn: AtomicU64::new(1),
}
}
/// The space's room, loaded from the store on first use. Pair with [`Hub::release`].
pub async fn acquire(&self, space: Uuid) -> Result<Arc<Room>> {
let mut rooms = self.rooms.lock().await;
if let Some(room) = rooms.get(&space) {
room.state.lock().await.holders += 1;
return Ok(room.clone());
}
let stored = self.store.load_doc(space).await?;
let doc = doc::load(&stored).with_context(|| format!("space {space} does not load"))?;
let (tx, _) = broadcast::channel(1024);
let room = Arc::new(Room {
space,
tx,
state: Mutex::new(RoomState {
awareness: Awareness::new(doc),
holders: 1,
last_update: stored.last_update,
latest: stored.latest,
dirty: (!stored.updates.is_empty()).then(|| Dirty {
since: Instant::now(),
last: Instant::now(),
edits: stored.updates.len() as u64,
}),
}),
});
rooms.insert(space, room.clone());
tracing::debug!(%space, "room opened");
Ok(room)
}
/// Lets go of a room; the last one out takes a snapshot and closes it.
pub async fn release(&self, room: &Arc<Room>) {
let mut rooms = self.rooms.lock().await;
let mut state = room.state.lock().await;
state.holders = state.holders.saturating_sub(1);
if state.holders > 0 {
return;
}
if let Err(error) = self.snapshot_locked(room.space, &mut state).await {
// The update log still has every edit; the room stays open for the next try.
tracing::error!(space = %room.space, %error, "snapshot on close failed");
return;
}
rooms.remove(&room.space);
tracing::debug!(space = %room.space, "room closed");
}
/// Snapshots every room whose edits are due under the [`SnapshotPolicy`].
pub async fn snapshot_idle_rooms(&self) {
let rooms: Vec<Arc<Room>> = self.rooms.lock().await.values().cloned().collect();
for room in rooms {
let mut state = room.state.lock().await;
if state.dirty.is_some_and(|dirty| self.snapshots.due(&dirty))
&& let Err(error) = self.snapshot_locked(room.space, &mut state).await
{
tracing::error!(space = %room.space, %error, "idle snapshot failed");
}
}
}
/// Snapshots every room with edits; on shutdown.
pub async fn snapshot_all(&self) {
let rooms: Vec<Arc<Room>> = self.rooms.lock().await.values().cloned().collect();
for room in rooms {
let mut state = room.state.lock().await;
if let Err(error) = self.snapshot_locked(room.space, &mut state).await {
tracing::error!(space = %room.space, %error, "snapshot on shutdown failed");
}
}
}
async fn snapshot_locked(
&self,
space: Uuid,
state: &mut RoomState,
) -> Result<Option<VersionRow>> {
if state.dirty.is_none() {
return Ok(None);
}
self.version_locked(space, state, VersionKind::Snapshot, None, None, None)
.await
.map(Some)
}
async fn version_locked(
&self,
space: Uuid,
state: &mut RoomState,
kind: VersionKind,
name: Option<&str>,
restored_from: Option<i64>,
by: Option<Uuid>,
) -> Result<VersionRow> {
let (major, minor) = match (&state.latest, kind) {
(None, VersionKind::Publish) => (1, 0),
(None, _) => (0, 1),
(Some(v), VersionKind::Publish) => (v.major + 1, 0),
(Some(v), _) => (v.major, v.minor + 1),
};
let (kind_name, counter) = match kind {
VersionKind::Snapshot => ("snapshot", &METRICS.snapshots_total),
VersionKind::Publish => ("publish", &METRICS.publishes_total),
VersionKind::Restore => ("restore", &METRICS.restores_total),
};
let encoded = doc::encode_state(state.awareness.doc());
let row = self
.store
.insert_version(
space,
major,
minor,
kind_name,
name,
&encoded,
state.last_update,
restored_from,
by,
)
.await?;
tracing::info!(%space, version = %format!("{major}.{minor}"), kind = kind_name, "version stored");
Metrics::inc(counter);
state.latest = Some(row.clone());
state.dirty = None;
Ok(row)
}
/// Marks the current state as a named, major version.
pub async fn publish(&self, space: Uuid, name: &str, by: Uuid) -> Result<VersionRow> {
let room = self.acquire(space).await?;
let result = {
let mut state = room.state.lock().await;
self.version_locked(
space,
&mut state,
VersionKind::Publish,
Some(name),
None,
Some(by),
)
.await
};
self.release(&room).await;
result
}
/// Brings back the files of an earlier version as a new edit, then stores a restore version.
pub async fn restore(&self, space: Uuid, version: i64, by: Uuid) -> Result<Option<VersionRow>> {
let Some((_, old_state)) = self.store.version(space, version).await? else {
return Ok(None);
};
let target = doc::files(&doc::from_state(&old_state)?);
let room = self.acquire(space).await?;
let result = async {
let mut state = room.state.lock().await;
let update = doc::replace_files(state.awareness.doc(), &target);
if update != EMPTY_UPDATE {
state.last_update = self.store.append_update(space, &update, Some(by)).await?;
state.edited();
let frame = Message::Sync(SyncMessage::Update(update)).encode_v1();
let _ = room.tx.send((0, Bytes::from(frame)));
}
self.version_locked(
space,
&mut state,
VersionKind::Restore,
None,
Some(version),
Some(by),
)
.await
}
.await;
self.release(&room).await;
result.map(Some)
}
/// Serves one WebSocket until it closes.
pub async fn serve(self: Arc<Self>, socket: WebSocket, space: Uuid, account: Uuid) {
let room = match self.acquire(space).await {
Ok(room) => room,
Err(error) => {
tracing::error!(%space, %error, "room does not open");
return;
}
};
let conn = self.next_conn.fetch_add(1, Ordering::Relaxed);
Metrics::inc(&METRICS.connections_total);
METRICS.connections_open.fetch_add(1, Ordering::Relaxed);
let mut clients = HashSet::new();
if let Err(error) = self.run(&room, socket, conn, account, &mut clients).await {
tracing::debug!(%space, conn, %error, "connection ended");
}
METRICS.connections_open.fetch_sub(1, Ordering::Relaxed);
self.leave_awareness(&room, conn, clients).await;
self.release(&room).await;
}
async fn run(
&self,
room: &Arc<Room>,
socket: WebSocket,
conn: u64,
account: Uuid,
clients: &mut HashSet<ClientID>,
) -> Result<()> {
let (mut sink, mut stream) = socket.split();
let mut rx = room.tx.subscribe();
// Ask for what the client has that the server lacks, and show who is here.
let hello = {
let state = room.state.lock().await;
let sv = state.awareness.doc().transact().state_vector();
let mut frames = vec![Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()];
let awareness = state.awareness.update()?;
if !awareness.clients.is_empty() {
frames.push(Message::Awareness(awareness).encode_v1());
}
frames
};
for frame in hello {
sink.send(WsMessage::Binary(frame.into())).await?;
}
loop {
tokio::select! {
incoming = stream.next() => {
let Some(incoming) = incoming else { return Ok(()) };
match incoming? {
WsMessage::Binary(data) => {
for reply in self.handle(room, conn, account, &data, clients).await? {
sink.send(WsMessage::Binary(reply.into())).await?;
}
}
WsMessage::Close(_) => return Ok(()),
_ => {}
}
}
outgoing = rx.recv() => match outgoing {
Ok((from, frame)) if from != conn => sink.send(WsMessage::Binary(frame)).await?,
Ok(_) => {}
// Too slow to keep up: close, and the client catches up on reconnect.
Err(broadcast::error::RecvError::Lagged(_)) => {
Metrics::inc(&METRICS.lagged_connections_total);
anyhow::bail!("connection lagged behind")
}
Err(broadcast::error::RecvError::Closed) => return Ok(()),
},
}
}
}
/// One frame from a client; returns the frames to answer with.
async fn handle(
&self,
room: &Arc<Room>,
conn: u64,
account: Uuid,
data: &[u8],
clients: &mut HashSet<ClientID>,
) -> Result<Vec<Vec<u8>>> {
match Message::decode_v1(data)? {
Message::Sync(SyncMessage::SyncStep1(sv)) => {
let state = room.state.lock().await;
let update = state
.awareness
.doc()
.transact()
.encode_state_as_update_v1(&sv);
Ok(vec![
Message::Sync(SyncMessage::SyncStep2(update)).encode_v1(),
])
}
Message::Sync(SyncMessage::SyncStep2(update))
| Message::Sync(SyncMessage::Update(update)) => {
if update.as_slice() == EMPTY_UPDATE {
return Ok(vec![]);
}
let decoded = Update::decode_v1(&update)?;
let mut state = room.state.lock().await;
// Durable first: the edit is in PostgreSQL before anyone else sees it.
let started = Instant::now();
state.last_update = self
.store
.append_update(room.space, &update, Some(account))
.await?;
METRICS.update_stored(update.len(), started.elapsed());
state.awareness.doc().transact_mut().apply_update(decoded)?;
state.edited();
let frame = Message::Sync(SyncMessage::Update(update)).encode_v1();
let _ = room.tx.send((conn, Bytes::from(frame)));
Ok(vec![])
}
Message::Awareness(update) => {
let mut state = room.state.lock().await;
if let Some(summary) = state.awareness.apply_update_summary(update)? {
clients.extend(summary.added.iter().chain(&summary.updated));
}
let _ = room.tx.send((conn, Bytes::copy_from_slice(data)));
Ok(vec![])
}
Message::AwarenessQuery => {
let state = room.state.lock().await;
Ok(vec![
Message::Awareness(state.awareness.update()?).encode_v1(),
])
}
Message::Auth(_) | Message::Custom(..) => Ok(vec![]),
}
}
/// Clears the awareness states a connection brought, and tells the others.
async fn leave_awareness(&self, room: &Arc<Room>, conn: u64, clients: HashSet<ClientID>) {
if clients.is_empty() {
return;
}
let mut state = room.state.lock().await;
for client in &clients {
state.awareness.remove_state(*client);
}
if let Ok(update) = state.awareness.update_with_clients(clients) {
let _ = room
.tx
.send((conn, Bytes::from(Message::Awareness(update).encode_v1())));
}
}
pub async fn open_rooms(&self) -> usize {
self.rooms.lock().await.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_busy_space_still_gets_snapshots() {
let policy = SnapshotPolicy {
idle: Duration::from_secs(30),
max_age: Duration::from_secs(600),
max_edits: 2000,
};
let now = Instant::now();
let typing = Dirty {
since: now,
last: now,
edits: 10,
};
assert!(!policy.due(&typing));
assert!(policy.due(&Dirty {
last: now - Duration::from_secs(31),
..typing
}));
assert!(policy.due(&Dirty {
since: now - Duration::from_secs(601),
..typing
}));
assert!(policy.due(&Dirty {
edits: 2000,
..typing
}));
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Archify Sync server: live, shared Archify models.
mod api;
mod auth;
mod config;
mod doc;
mod error;
mod hub;
mod metrics;
mod oidc;
mod pages;
mod state;
use std::time::Duration;
use anyhow::Result;
use archify_sync_store::Store;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, post};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
// The image has no shell or curl; `archify-sync-server healthcheck` is its probe.
if std::env::args().nth(1).as_deref() == Some("healthcheck") {
return healthcheck().await;
}
// reqwest (single sign-on) and sqlx share rustls with the ring provider.
let _ = rustls::crypto::ring::default_provider().install_default();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn"));
if std::env::var("ARCHIFY_SYNC_LOG_JSON").is_ok_and(|v| v == "true") {
tracing_subscriber::fmt()
.with_env_filter(filter)
.json()
.init();
} else {
tracing_subscriber::fmt().with_env_filter(filter).init();
}
let config = Config::from_env()?;
let store = Store::connect(&config.database_url, config.database_max_connections).await?;
store.migrate().await?;
let listen = config.listen;
let state = AppState::new(config, store).await?;
let background = tokio::spawn(housekeeping(state.clone()));
if let Some(address) = state.config.metrics_listen {
let listener = tokio::net::TcpListener::bind(address).await?;
tracing::info!(listen = %address, "metrics listening");
let app = Router::new()
.route("/metrics", get(metrics::serve))
.with_state(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await });
}
let listener = tokio::net::TcpListener::bind(listen).await?;
tracing::info!(%listen, version = env!("CARGO_PKG_VERSION"), "Archify Sync listening");
axum::serve(listener, router(state.clone()))
.with_graceful_shutdown(shutdown())
.await?;
background.abort();
state.hub.snapshot_all().await;
tracing::info!("stopped");
Ok(())
}
pub fn router(state: AppState) -> Router {
let v1 = Router::new()
.route("/info", get(api::info))
.route("/setup", post(api::setup))
.route("/auth/token", post(api::token))
.route("/auth/logout", post(api::logout))
.route("/me", get(api::me))
.route("/spaces", get(api::list_spaces).post(api::create_space))
.route("/spaces/{space}", get(api::get_space))
.route("/spaces/{space}/members", get(api::members))
.route(
"/spaces/{space}/members/{account}",
delete(api::remove_member),
)
.route("/spaces/{space}/invites", post(api::create_invite))
.route("/invites/{token}/accept", post(api::accept_invite))
.route(
"/spaces/{space}/versions",
get(api::versions).post(api::publish),
)
.route(
"/spaces/{space}/versions/{version}/files",
get(api::version_files),
)
.route(
"/spaces/{space}/versions/{version}/restore",
post(api::restore),
)
.route("/spaces/{space}/sync", get(api::sync));
Router::new()
.nest("/v1", v1)
.route("/healthz", get(api::healthz))
.route("/readyz", get(api::readyz))
.route("/setup", get(pages::setup_page).post(pages::setup_submit))
.route(
"/authorize",
get(pages::authorize_page).post(pages::authorize_submit),
)
.route("/join/{token}", get(pages::join_page))
.route("/oidc/start", get(pages::oidc_start))
.route("/oidc/callback", get(pages::oidc_callback))
.layer(DefaultBodyLimit::max(1024 * 1024))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
/// Idle snapshots every few seconds; expired sessions once an hour.
async fn housekeeping(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(5));
let mut n: u64 = 0;
loop {
tick.tick().await;
state.hub.snapshot_idle_rooms().await;
n += 1;
if n.is_multiple_of(720) {
match state.store.delete_expired_refresh().await {
Ok(removed) if removed > 0 => tracing::info!(removed, "expired sessions removed"),
Ok(_) => {}
Err(error) => tracing::warn!(%error, "removing expired sessions failed"),
}
let rooms = state.hub.open_rooms().await;
tracing::debug!(rooms, "open rooms");
}
}
}
/// Asks the running server's `/healthz` on this machine; exits non-zero if it does not answer.
async fn healthcheck() -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listen: std::net::SocketAddr = std::env::var("ARCHIFY_SYNC_LISTEN")
.unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()?;
let target = std::net::SocketAddr::from(([127, 0, 0, 1], listen.port()));
let reply = tokio::time::timeout(Duration::from_secs(3), async {
let mut stream = tokio::net::TcpStream::connect(target).await?;
stream
.write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await?;
let mut reply = String::new();
stream.read_to_string(&mut reply).await?;
anyhow::Ok(reply)
})
.await??;
anyhow::ensure!(reply.starts_with("HTTP/1.1 200"), "unhealthy");
Ok(())
}
async fn shutdown() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let term = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
s.recv().await;
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
tracing::info!("shutting down; storing snapshots");
}
+151
View File
@@ -0,0 +1,151 @@
//! Prometheus metrics, served on their own address (`ARCHIFY_SYNC_METRICS_LISTEN`)
//! so they never reach the public one.
use std::fmt::Write;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::time::Duration;
use axum::extract::State;
use axum::http::header;
use axum::response::IntoResponse;
use crate::state::AppState;
pub static METRICS: Metrics = Metrics::new();
pub struct Metrics {
pub connections_total: AtomicU64,
pub connections_open: AtomicI64,
pub updates_total: AtomicU64,
pub update_bytes_total: AtomicU64,
/// Time to store an update in PostgreSQL, in microseconds.
update_store_micros_total: AtomicU64,
pub snapshots_total: AtomicU64,
pub publishes_total: AtomicU64,
pub restores_total: AtomicU64,
pub sign_in_failures_total: AtomicU64,
pub sign_in_blocked_total: AtomicU64,
pub refresh_reuse_total: AtomicU64,
pub lagged_connections_total: AtomicU64,
}
impl Metrics {
const fn new() -> Self {
Self {
connections_total: AtomicU64::new(0),
connections_open: AtomicI64::new(0),
updates_total: AtomicU64::new(0),
update_bytes_total: AtomicU64::new(0),
update_store_micros_total: AtomicU64::new(0),
snapshots_total: AtomicU64::new(0),
publishes_total: AtomicU64::new(0),
restores_total: AtomicU64::new(0),
sign_in_failures_total: AtomicU64::new(0),
sign_in_blocked_total: AtomicU64::new(0),
refresh_reuse_total: AtomicU64::new(0),
lagged_connections_total: AtomicU64::new(0),
}
}
pub fn inc(counter: &AtomicU64) {
counter.fetch_add(1, Ordering::Relaxed);
}
pub fn update_stored(&self, bytes: usize, took: Duration) {
self.updates_total.fetch_add(1, Ordering::Relaxed);
self.update_bytes_total
.fetch_add(bytes as u64, Ordering::Relaxed);
self.update_store_micros_total
.fetch_add(took.as_micros() as u64, Ordering::Relaxed);
}
}
pub async fn serve(State(state): State<AppState>) -> impl IntoResponse {
let m = &METRICS;
let get = |c: &AtomicU64| c.load(Ordering::Relaxed);
let mut out = String::new();
let mut metric = |name: &str, kind: &str, help: &str, value: String| {
let _ = writeln!(out, "# HELP archify_sync_{name} {help}");
let _ = writeln!(out, "# TYPE archify_sync_{name} {kind}");
let _ = writeln!(out, "archify_sync_{name} {value}");
};
metric(
"rooms_open",
"gauge",
"Spaces open in memory.",
state.hub.open_rooms().await.to_string(),
);
metric(
"connections_open",
"gauge",
"Live editor connections.",
m.connections_open.load(Ordering::Relaxed).to_string(),
);
metric(
"connections_total",
"counter",
"Editor connections since start.",
get(&m.connections_total).to_string(),
);
metric(
"connections_lagged_total",
"counter",
"Connections closed for falling behind.",
get(&m.lagged_connections_total).to_string(),
);
metric(
"updates_total",
"counter",
"Edits stored.",
get(&m.updates_total).to_string(),
);
metric(
"update_bytes_total",
"counter",
"Bytes of edits stored.",
get(&m.update_bytes_total).to_string(),
);
metric(
"update_store_seconds_total",
"counter",
"Time spent storing edits in PostgreSQL.",
format!("{:.6}", get(&m.update_store_micros_total) as f64 / 1e6),
);
metric(
"snapshots_total",
"counter",
"Snapshot versions taken.",
get(&m.snapshots_total).to_string(),
);
metric(
"publishes_total",
"counter",
"Versions published.",
get(&m.publishes_total).to_string(),
);
metric(
"restores_total",
"counter",
"Versions restored.",
get(&m.restores_total).to_string(),
);
metric(
"sign_in_failures_total",
"counter",
"Sign-ins with a wrong name or password.",
get(&m.sign_in_failures_total).to_string(),
);
metric(
"sign_in_blocked_total",
"counter",
"Sign-ins refused for too many failures.",
get(&m.sign_in_blocked_total).to_string(),
);
metric(
"refresh_reuse_total",
"counter",
"Sessions signed out for a reused refresh token.",
get(&m.refresh_reuse_total).to_string(),
);
([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], out)
}
+368
View File
@@ -0,0 +1,368 @@
//! Single sign-on through an OpenID Connect provider (Keycloak, Authentik,
//! Entra ID, …). The editor's sign-in stays as it is: the browser page offers
//! "Sign in with …", the provider signs the person in and comes back to
//! `/oidc/callback`, and the server hands the editor its one-time code as for
//! a password sign-in. One provider client serves every address the server is
//! reached at; each address only needs its callback listed at the provider.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail, ensure};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::auth::random_token;
use crate::pages::AuthorizeQuery;
/// How long the provider may take to send the person back.
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
const PENDING_MAX: usize = 10_000;
const CACHE_TTL: Duration = Duration::from_secs(60 * 60);
/// Unknown signing keys trigger a fresh key set at most this often.
const JWKS_REFRESH_MIN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct OidcConfig {
/// `https://keycloak.example.com/realms/home`, as the provider names itself.
pub issuer: String,
pub client_id: String,
/// Confidential clients (Keycloak's default) have one; public clients rely on PKCE alone.
pub client_secret: Option<String>,
/// The button: "Sign in with {label}".
pub label: String,
pub scopes: String,
/// The claim that lists the person's groups (Keycloak's group mapper: `groups`).
pub groups_claim: String,
/// When set, only members of one of these groups may sign in, and they
/// get an account on first sign-in without an invite.
pub allowed_groups: Vec<String>,
/// Members of these groups are admins of the server.
pub admin_groups: Vec<String>,
}
/// Who the provider says signed in.
#[derive(Debug, Clone)]
pub struct Person {
pub subject: String,
/// `preferred_username`, or the part of the e-mail before the `@`.
pub username: Option<String>,
pub display_name: Option<String>,
pub email: Option<String>,
pub groups: Vec<String>,
}
impl Person {
pub fn in_any(&self, groups: &[String]) -> bool {
self.groups.iter().any(|g| groups.contains(g))
}
}
#[derive(Debug, Clone, Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
}
struct Pending {
query: AuthorizeQuery,
nonce: String,
verifier: String,
created: Instant,
}
#[derive(Deserialize)]
struct TokenReply {
id_token: String,
}
pub struct Oidc {
pub config: OidcConfig,
http: reqwest::Client,
discovery: Mutex<Option<(Instant, Discovery)>>,
jwks: Mutex<Option<(Instant, JwkSet)>>,
pending: Mutex<HashMap<String, Pending>>,
}
impl Oidc {
pub fn new(config: OidcConfig) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self {
config,
http,
discovery: Mutex::new(None),
jwks: Mutex::new(None),
pending: Mutex::new(HashMap::new()),
})
}
/// Where the provider sends people back; list it in the provider's client.
pub fn callback_url(public_url: &str) -> String {
format!("{public_url}/oidc/callback")
}
async fn discovery(&self) -> Result<Discovery> {
let mut cached = self.discovery.lock().await;
if let Some((at, d)) = cached.as_ref()
&& at.elapsed() < CACHE_TTL
{
return Ok(d.clone());
}
let url = format!(
"{}/.well-known/openid-configuration",
self.config.issuer.trim_end_matches('/')
);
let d: Discovery = self
.http
.get(&url)
.send()
.await
.and_then(|r| r.error_for_status())
.with_context(|| format!("the identity provider does not answer at {url}"))?
.json()
.await
.context("the identity provider's configuration does not read")?;
ensure!(
d.issuer.trim_end_matches('/') == self.config.issuer.trim_end_matches('/'),
"the identity provider calls itself {}, not {}",
d.issuer,
self.config.issuer
);
*cached = Some((Instant::now(), d.clone()));
Ok(d)
}
async fn key_for(&self, kid: Option<&str>) -> Result<DecodingKey> {
let d = self.discovery().await?;
let mut cached = self.jwks.lock().await;
let find = |set: &JwkSet| match kid {
Some(kid) => set.find(kid).cloned(),
None => set.keys.first().cloned(),
};
let stale = cached.as_ref().is_none_or(|(at, set)| {
at.elapsed() >= CACHE_TTL || (find(set).is_none() && at.elapsed() >= JWKS_REFRESH_MIN)
});
if stale {
let set: JwkSet = self
.http
.get(&d.jwks_uri)
.send()
.await
.and_then(|r| r.error_for_status())
.context("the identity provider's signing keys do not load")?
.json()
.await
.context("the identity provider's signing keys do not read")?;
*cached = Some((Instant::now(), set));
}
let (_, set) = cached.as_ref().expect("filled above");
let jwk = find(set).ok_or_else(|| anyhow!("the sign-in was signed with an unknown key"))?;
Ok(DecodingKey::from_jwk(&jwk)?)
}
/// The provider's sign-in address for this editor sign-in.
pub async fn start(&self, public_url: &str, query: AuthorizeQuery) -> Result<String> {
let d = self.discovery().await?;
let state = random_token();
let nonce = random_token();
let verifier = random_token();
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
{
let mut pending = self.pending.lock().await;
pending.retain(|_, p| p.created.elapsed() < PENDING_TTL);
ensure!(
pending.len() < PENDING_MAX,
"too many sign-ins are waiting; try again in a few minutes"
);
pending.insert(
state.clone(),
Pending {
query,
nonce: nonce.clone(),
verifier,
created: Instant::now(),
},
);
}
let mut url = url::Url::parse(&d.authorization_endpoint)
.context("the identity provider's sign-in address is not valid")?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.config.client_id)
.append_pair("redirect_uri", &Self::callback_url(public_url))
.append_pair("scope", &self.config.scopes)
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
Ok(url.into())
}
/// The provider sent the person back: exchanges the code and checks the ID token.
pub async fn finish(
&self,
public_url: &str,
code: &str,
state: &str,
) -> Result<(AuthorizeQuery, Person)> {
let pending = self
.pending
.lock()
.await
.remove(state)
.filter(|p| p.created.elapsed() < PENDING_TTL)
.ok_or_else(|| anyhow!("this sign-in has expired; start again from VS Code"))?;
let d = self.discovery().await?;
let redirect_uri = Self::callback_url(public_url);
let mut form = vec![
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri.as_str()),
("client_id", self.config.client_id.as_str()),
("code_verifier", pending.verifier.as_str()),
];
if let Some(secret) = &self.config.client_secret {
form.push(("client_secret", secret.as_str()));
}
let response = self
.http
.post(&d.token_endpoint)
.form(&form)
.send()
.await
.context("the identity provider does not answer")?;
if !response.status().is_success() {
let status = response.status();
let body: Value = response.json().await.unwrap_or(Value::Null);
// The provider's own words (invalid_client, …); never the request itself.
bail!(
"the identity provider refused the sign-in ({status}{})",
body.get("error")
.and_then(Value::as_str)
.map(|e| format!(", {e}"))
.unwrap_or_default()
);
}
let reply: TokenReply = response
.json()
.await
.context("the identity provider sent no ID token")?;
let person = self
.check_id_token(&d, &reply.id_token, &pending.nonce)
.await?;
Ok((pending.query, person))
}
async fn check_id_token(&self, d: &Discovery, token: &str, nonce: &str) -> Result<Person> {
let header = decode_header(token).context("the ID token does not read")?;
let key = self.key_for(header.kid.as_deref()).await?;
let mut validation = Validation::new(header.alg);
validation.set_issuer(&[d.issuer.as_str()]);
validation.set_audience(&[self.config.client_id.as_str()]);
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
validation.leeway = 60;
let claims: HashMap<String, Value> = decode(token, &key, &validation)
.context("the ID token is not valid")?
.claims;
ensure!(
claims.get("nonce").and_then(Value::as_str) == Some(nonce),
"the ID token belongs to another sign-in"
);
Ok(person_from(&claims, &self.config.groups_claim))
}
}
fn person_from(claims: &HashMap<String, Value>, groups_claim: &str) -> Person {
let text = |key: &str| {
claims
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
};
let email = text("email");
let groups = match claims.get(groups_claim) {
Some(Value::Array(items)) => items
.iter()
.filter_map(Value::as_str)
// Keycloak sends "/team" when the mapper keeps full paths.
.map(|g| g.trim_start_matches('/').to_owned())
.collect(),
Some(Value::String(one)) => vec![one.trim_start_matches('/').to_owned()],
_ => vec![],
};
Person {
subject: text("sub").unwrap_or_default(),
username: text("preferred_username").or_else(|| {
email
.as_deref()
.and_then(|e| e.split('@').next())
.map(str::to_owned)
}),
display_name: text("name"),
email,
groups,
}
}
/// A sign-in name from what the provider calls the person: letters, digits and `. _ - @`.
pub fn account_name(person: &Person) -> String {
let raw = person.username.as_deref().unwrap_or("user");
let mut name: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@') {
c.to_ascii_lowercase()
} else {
'-'
}
})
.take(56)
.collect();
if name.len() < 2 {
name = format!("user-{name}");
}
name
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn people_from_keycloak_claims() {
let claims: HashMap<String, Value> = serde_json::from_value(serde_json::json!({
"sub": "f3c1",
"preferred_username": "Ana Šmit",
"name": "Ana Šmit",
"email": "ana@example.com",
"groups": ["/archify-users", "admins"],
}))
.unwrap();
let person = person_from(&claims, "groups");
assert_eq!(person.subject, "f3c1");
assert_eq!(person.groups, ["archify-users", "admins"]);
assert!(person.in_any(&["archify-users".into()]));
assert!(!person.in_any(&["others".into()]));
assert_eq!(account_name(&person), "ana--mit");
let bare: HashMap<String, Value> =
serde_json::from_value(serde_json::json!({ "sub": "x", "email": "bo@example.com" }))
.unwrap();
let person = person_from(&bare, "groups");
assert_eq!(account_name(&person), "bo");
assert!(person.groups.is_empty());
}
}
+574
View File
@@ -0,0 +1,574 @@
//! The few pages people open in a browser: first-start setup, sign-in for
//! the editor (with sign-up through an invite), and the join page an invite
//! link opens.
use archify_sync_protocol::SetupRequest;
use archify_sync_store::{AccountRow, InviteOutcome};
use axum::Form;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use serde::{Deserialize, Serialize};
use crate::api;
use crate::auth;
use crate::error::ApiErr;
use crate::metrics::{METRICS, Metrics};
use crate::oidc::{self, Person};
use crate::state::AppState;
const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' vscode: vscode-insiders: vscodium: cursor: windsurf: http://127.0.0.1:* http://localhost:*; frame-ancestors 'none'; base-uri 'none'";
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn page(state: &AppState, title: &str, body: &str) -> Response {
let html = format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · {server}</title><style>
:root {{ color-scheme: light dark; --fg: #1f2328; --muted: #59636e; --bg: #f6f8fa; --card: #fff; --line: #d1d9e0; --accent: #0969da; --bad: #d1242f; }}
@media (prefers-color-scheme: dark) {{ :root {{ --fg: #e6edf3; --muted: #9198a1; --bg: #0d1117; --card: #151b23; --line: #3d444d; --accent: #4493f8; --bad: #f85149; }} }}
body {{ margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); }}
main {{ max-width: 420px; margin: 48px auto; padding: 0 16px; }}
.card {{ background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 24px; margin-bottom: 16px; }}
h1 {{ font-size: 20px; margin: 0 0 4px; }} h2 {{ font-size: 16px; margin: 0 0 12px; }}
p.muted {{ color: var(--muted); margin: 0 0 16px; }}
label {{ display: block; margin: 12px 0 4px; font-weight: 600; }}
input {{ width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; font: inherit; }}
button, a.button {{ display: inline-block; margin-top: 16px; padding: 8px 16px; border: 0; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; }}
.error {{ color: var(--bad); margin: 0 0 12px; }} code {{ font-size: 14px; }}
</style></head><body><main><h1>{server}</h1><p class="muted">Archify Sync</p>{body}</main></body></html>"#,
title = escape(title),
server = escape(&state.config.server_name),
);
let mut response = Html(html).into_response();
let headers = response.headers_mut();
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(CSP),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
fn error_line(error: Option<&str>) -> String {
error
.map(|e| format!(r#"<p class="error">{}</p>"#, escape(e)))
.unwrap_or_default()
}
fn hidden(name: &str, value: &str) -> String {
format!(
r#"<input type="hidden" name="{name}" value="{}">"#,
escape(value)
)
}
// ------------------------------------------------------------------- setup
pub async fn setup_page(State(state): State<AppState>) -> Response {
setup_form(&state, None).await
}
async fn setup_form(state: &AppState, error: Option<&str>) -> Response {
if state.first_start.lock().await.is_none() {
return page(
state,
"Set up",
r#"<div class="card"><h2>Set up</h2><p>This server is set up. Sign in from VS Code with <b>Archify: Sign in to Sync Server…</b></p></div>"#,
);
}
let body = format!(
r#"<div class="card"><h2>Set up the first admin</h2><p class="muted">The first-start code is in the server's log.</p>{}
<form method="post" action="/setup">
<label for="code">First-start code</label><input id="code" name="code" autocomplete="off" required>
<label for="organisation">Organisation</label><input id="organisation" name="organisation" required>
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
<label for="display_name">Your name</label><input id="display_name" name="display_name">
<label for="password">Password (10 characters or more)</label><input id="password" name="password" type="password" autocomplete="new-password" required>
<button type="submit">Set up</button></form></div>"#,
error_line(error)
);
page(state, "Set up", &body)
}
#[derive(Deserialize)]
pub struct SetupForm {
code: String,
organisation: String,
name: String,
display_name: Option<String>,
password: String,
}
pub async fn setup_submit(State(state): State<AppState>, Form(form): Form<SetupForm>) -> Response {
let req = SetupRequest {
code: form.code,
organisation: form.organisation,
name: form.name,
display_name: form.display_name,
password: form.password,
};
match api::setup_admin(&state, &req).await {
Ok(account) => page(
&state,
"Set up",
&format!(
r#"<div class="card"><h2>Done</h2><p>{} is the admin. In VS Code, run <b>Archify: Sign in to Sync Server…</b> with <code>{}</code>.</p></div>"#,
escape(&account.display_name),
escape(&state.config.public_url)
),
),
Err(e) => setup_form(&state, Some(&e.message)).await,
}
}
// --------------------------------------------------------------- sign-in
/// The editor's sign-in request, carried through the password form or the identity provider.
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AuthorizeQuery {
response_type: String,
redirect_uri: String,
state: String,
code_challenge: String,
code_challenge_method: String,
invite: Option<String>,
}
impl AuthorizeQuery {
fn to_query_string(&self) -> String {
let mut q = url::form_urlencoded::Serializer::new(String::new());
q.append_pair("response_type", &self.response_type)
.append_pair("redirect_uri", &self.redirect_uri)
.append_pair("state", &self.state)
.append_pair("code_challenge", &self.code_challenge)
.append_pair("code_challenge_method", &self.code_challenge_method);
if let Some(invite) = &self.invite {
q.append_pair("invite", invite);
}
q.finish()
}
}
#[derive(Deserialize)]
pub struct AuthorizeForm {
#[serde(flatten)]
query: AuthorizeQuery,
/// `signin` or `signup`.
action: String,
name: String,
password: String,
display_name: Option<String>,
}
fn check_authorize(state: &AppState, q: &AuthorizeQuery) -> Result<(), &'static str> {
if q.response_type != "code" || q.code_challenge_method != "S256" || q.code_challenge.len() < 43
{
return Err("This sign-in request is not valid. Start again from VS Code.");
}
if !auth::redirect_allowed(state, &q.redirect_uri) {
return Err("This sign-in request does not come from the Archify extension.");
}
Ok(())
}
pub async fn authorize_page(
State(state): State<AppState>,
Query(q): Query<AuthorizeQuery>,
) -> Response {
authorize_form(&state, &q, None).await
}
async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str>) -> Response {
if let Err(message) = check_authorize(state, q) {
let mut response = page(
state,
"Sign in",
&format!(r#"<div class="card">{}</div>"#, error_line(Some(message))),
);
*response.status_mut() = StatusCode::BAD_REQUEST;
return response;
}
let fields = [
hidden("response_type", &q.response_type),
hidden("redirect_uri", &q.redirect_uri),
hidden("state", &q.state),
hidden("code_challenge", &q.code_challenge),
hidden("code_challenge_method", &q.code_challenge_method),
q.invite
.as_deref()
.map(|i| hidden("invite", i))
.unwrap_or_default(),
]
.concat();
// Single sign-on first when the server has it; the password form stays for local accounts.
let mut body = state
.oidc
.as_ref()
.map(|o| {
format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
error_line(error),
escape(&q.to_query_string()),
escape(&o.config.label),
)
})
.unwrap_or_default();
let (title, error) = if body.is_empty() {
("Sign in for VS Code", error)
} else {
("Or with a password", None)
};
body.push_str(&format!(
r#"<div class="card"><h2>{title}</h2>{}
<form method="post" action="/authorize">{fields}{}
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button></form></div>"#,
error_line(error),
hidden("action", "signin"),
));
// A valid invite lets someone new make an account in the space's organisation.
if let Some(invite) = &q.invite
&& let Ok(Some((_, space_name))) =
state.store.invite_organisation(&auth::sha256(invite)).await
{
body.push_str(&format!(
r#"<div class="card"><h2>New here?</h2><p class="muted">You were invited to <b>{}</b>. Make an account.</p>
<form method="post" action="/authorize">{fields}{}
<label for="new-name">Sign-in name</label><input id="new-name" name="name" autocomplete="username" required>
<label for="new-display">Your name</label><input id="new-display" name="display_name">
<label for="new-password">Password (10 characters or more)</label><input id="new-password" name="password" type="password" autocomplete="new-password" required>
<button type="submit">Make account</button></form></div>"#,
escape(&space_name),
hidden("action", "signup"),
));
}
page(state, "Sign in", &body)
}
pub async fn authorize_submit(
State(state): State<AppState>,
Form(form): Form<AuthorizeForm>,
) -> Response {
let q = form.query.clone();
if check_authorize(&state, &q).is_err() {
return authorize_form(&state, &q, None).await;
}
let account = match form.action.as_str() {
"signup" => sign_up(&state, &form).await,
_ => sign_in(&state, &form).await,
};
match account {
Ok(account) => complete(&state, &q, &account).await,
Err(e) => authorize_form(&state, &q, Some(&e.message)).await,
}
}
/// Signed in: joins the invite's space, if any, and sends the editor its one-time code.
async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) -> Response {
if let Some(invite) = &q.invite
&& let Ok(InviteOutcome::Invalid) = state
.store
.accept_invite(&auth::sha256(invite), account)
.await
{
tracing::info!(account = %account.id, "signed in with an invite that is no longer valid");
}
let code = state
.codes
.insert(account.id, q.code_challenge.clone(), q.redirect_uri.clone())
.await;
let Ok(mut target) = url::Url::parse(&q.redirect_uri) else {
return authorize_form(state, q, None).await;
};
target
.query_pairs_mut()
.append_pair("code", &code)
.append_pair("state", &q.state);
Redirect::to(target.as_str()).into_response()
}
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
let name = form.name.trim();
if state.sign_in_limits.blocked(name).await {
Metrics::inc(&METRICS.sign_in_blocked_total);
return Err(ApiErr::too_many(
"Too many wrong passwords for this name. Try again in 15 minutes.",
));
}
let account = state.store.account_by_name(name).await?;
match account {
Some(a)
if a.password_hash
.as_deref()
.is_some_and(|h| auth::verify_password(&form.password, h)) =>
{
state.sign_in_limits.succeeded(name).await;
Ok(a)
}
_ => {
Metrics::inc(&METRICS.sign_in_failures_total);
state.sign_in_limits.failed(name).await;
// The same work whether or not the name exists.
if account_missing(&account) {
let _ = auth::hash_password(&form.password);
}
Err(ApiErr::unauthorized("The name or the password is wrong."))
}
}
}
fn account_missing(account: &Option<AccountRow>) -> bool {
account
.as_ref()
.and_then(|a| a.password_hash.as_ref())
.is_none()
}
async fn sign_up(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
let invite = form
.query
.invite
.as_deref()
.ok_or_else(|| ApiErr::forbidden("New accounts need an invite link."))?;
let (organisation, _) = state
.store
.invite_organisation(&auth::sha256(invite))
.await?
.ok_or_else(|| ApiErr::forbidden("This invite link has expired or was used up."))?;
let name = form.name.trim();
auth::check_name(name)?;
auth::check_new_password(&form.password)?;
let hash = auth::hash_password(&form.password)?;
let display = form
.display_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(name);
Ok(state
.store
.create_account(organisation, name, display, &hash)
.await?)
}
// ------------------------------------------------------------------- join
pub async fn join_page(State(state): State<AppState>, Path(token): Path<String>) -> Response {
let space = state
.store
.invite_organisation(&auth::sha256(&token))
.await
.ok()
.flatten();
let Some((_, space_name)) = space else {
let mut response = page(
&state,
"Join",
r#"<div class="card"><h2>Join</h2><p class="error">This invite link has expired or was used up. Ask for a new one.</p></div>"#,
);
*response.status_mut() = StatusCode::NOT_FOUND;
return response;
};
let link = |scheme: &str| {
let mut url = url::Url::parse(&format!("{scheme}://{}/join", state.config.extension_id))
.expect("valid base");
url.query_pairs_mut()
.append_pair("server", &state.config.public_url)
.append_pair("invite", &token);
escape(url.as_str())
};
let body = format!(
r#"<div class="card"><h2>Join {space}</h2><p>You were invited to work on the model <b>{space}</b> together.</p>
<p class="muted">You need the Archify Diagram Viewer extension.</p>
<a class="button" href="{vscode}">Open in VS Code</a></div>
<div class="card"><p class="muted">Other editors: <a href="{codium}">VSCodium</a> · <a href="{cursor}">Cursor</a></p></div>"#,
space = escape(&space_name),
vscode = link("vscode"),
codium = link("vscodium"),
cursor = link("cursor"),
);
page(&state, "Join", &body)
}
// --------------------------------------------------------- single sign-on
pub async fn oidc_start(
State(state): State<AppState>,
Query(q): Query<AuthorizeQuery>,
) -> Response {
let Some(oidc) = &state.oidc else {
return StatusCode::NOT_FOUND.into_response();
};
if check_authorize(&state, &q).is_err() {
return authorize_form(&state, &q, None).await;
}
match oidc.start(&state.config.public_url, q.clone()).await {
Ok(url) => Redirect::to(&url).into_response(),
Err(error) => {
tracing::warn!(%error, "single sign-on did not start");
authorize_form(
&state,
&q,
Some(&format!("Single sign-on is not available: {error}.")),
)
.await
}
}
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
fn sso_problem(state: &AppState, message: &str) -> Response {
let mut response = page(
state,
"Sign in",
&format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<p class="muted">Close this tab and sign in again from VS Code.</p></div>"#,
error_line(Some(message))
),
);
*response.status_mut() = StatusCode::BAD_REQUEST;
response
}
pub async fn oidc_callback(
State(state): State<AppState>,
Query(cb): Query<CallbackQuery>,
) -> Response {
let Some(oidc) = &state.oidc else {
return StatusCode::NOT_FOUND.into_response();
};
if let Some(error) = cb.error {
let detail = cb.error_description.unwrap_or(error);
return sso_problem(
&state,
&format!("The identity provider did not sign you in: {detail}"),
);
}
let (Some(code), Some(sso_state)) = (cb.code, cb.state) else {
return sso_problem(&state, "The identity provider sent no sign-in.");
};
let (q, person) = match oidc
.finish(&state.config.public_url, &code, &sso_state)
.await
{
Ok(found) => found,
Err(error) => {
Metrics::inc(&METRICS.sign_in_failures_total);
tracing::warn!(%error, "single sign-on failed");
return sso_problem(&state, &format!("Single sign-on failed: {error}."));
}
};
match sso_account(&state, &q, &person).await {
Ok(account) => {
tracing::info!(account = %account.id, name = %account.name, "signed in through single sign-on");
complete(&state, &q, &account).await
}
Err(e) => {
Metrics::inc(&METRICS.sign_in_failures_total);
authorize_form(&state, &q, Some(&e.message)).await
}
}
}
/// The account of a person the identity provider signed in: found by the
/// provider's subject, or made on the first sign-in when an invite or the
/// allowed groups let them in.
async fn sso_account(
state: &AppState,
q: &AuthorizeQuery,
person: &Person,
) -> Result<AccountRow, ApiErr> {
let config = &state.oidc.as_ref().expect("checked by the caller").config;
if person.subject.is_empty() {
return Err(ApiErr::unauthorized(
"The identity provider did not say who you are.",
));
}
let admin = person.in_any(&config.admin_groups);
if !config.allowed_groups.is_empty() && !admin && !person.in_any(&config.allowed_groups) {
return Err(ApiErr::forbidden(format!(
"{} signed you in, but your account there is not in a group this server allows.",
config.label
)));
}
if let Some(mut account) = state
.store
.account_by_identity(&config.issuer, &person.subject)
.await?
{
if admin && account.role != "admin" {
state.store.set_role(account.id, "admin").await?;
account.role = "admin".into();
}
return Ok(account);
}
let organisation = match q.invite.as_deref() {
Some(invite) => state
.store
.invite_organisation(&auth::sha256(invite))
.await?
.map(|(org, _)| org),
None if !config.allowed_groups.is_empty() || admin => {
state.store.first_organisation().await?
}
None => None,
};
let Some(organisation) = organisation else {
return Err(ApiErr::forbidden(format!(
"{} signed you in, but you have no account on this server yet. Ask someone for an invite link and open it.",
config.label
)));
};
let name = oidc::account_name(person);
let display = person
.display_name
.clone()
.or_else(|| person.username.clone())
.unwrap_or_else(|| name.clone());
let role = if admin { "admin" } else { "member" };
match state
.store
.create_sso_account(
organisation,
&name,
&display,
role,
&config.issuer,
&person.subject,
person.email.as_deref(),
)
.await
{
Ok(account) => Ok(account),
// Two sign-ins of the same person at once: the other one made the account.
Err(archify_sync_store::StoreError::Conflict("identity")) => state
.store
.account_by_identity(&config.issuer, &person.subject)
.await?
.ok_or_else(|| ApiErr::unauthorized("Sign in again.")),
Err(e) => Err(e.into()),
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::sync::Arc;
use archify_sync_store::Store;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use tokio::sync::Mutex;
use crate::auth::{self, Keys, PendingCodes, SignInLimits};
use crate::config::Config;
use crate::hub::Hub;
use crate::oidc::Oidc;
#[derive(Clone)]
pub struct AppState(Arc<Inner>);
pub struct Inner {
pub config: Config,
pub store: Store,
pub hub: Arc<Hub>,
pub keys: Keys,
pub codes: PendingCodes,
pub sign_in_limits: SignInLimits,
pub oidc: Option<Oidc>,
/// The first-start code while the server has no accounts; printed in the log.
pub first_start: Mutex<Option<String>>,
}
impl std::ops::Deref for AppState {
type Target = Inner;
fn deref(&self) -> &Inner {
&self.0
}
}
impl AppState {
pub async fn new(config: Config, store: Store) -> anyhow::Result<Self> {
// One signing key per server, shared by replicas through the database.
let generated = STANDARD.encode(auth::random_token());
let stored = store
.setting_or_insert("jwt_key", serde_json::Value::String(generated))
.await?;
let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?;
anyhow::ensure!(secret.len() >= 32, "the stored signing key is too short");
let first_start = if store.account_count().await? == 0 {
let code = auth::first_start_code();
tracing::warn!(
"first start: no accounts yet. Set up the first admin at {}/setup with code {code}",
config.public_url
);
Some(code)
} else {
None
};
let oidc = match &config.oidc {
Some(c) => {
tracing::info!(
issuer = %c.issuer,
client = %c.client_id,
callback = %Oidc::callback_url(&config.public_url),
"single sign-on is on"
);
Some(Oidc::new(c.clone())?)
}
None => None,
};
let hub = Arc::new(Hub::new(store.clone(), config.snapshots));
Ok(Self(Arc::new(Inner {
keys: Keys::from_secret(&secret),
codes: PendingCodes::default(),
sign_in_limits: SignInLimits::default(),
oidc,
first_start: Mutex::new(first_start),
config,
store,
hub,
})))
}
}