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:
Oleg Lukasonok committed 2026-11-06 12:06:59 +02:00
commit 99778d7489
67 files changed
+8362

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
[package]
name = "archify-sync-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
chrono = { version = "0.4.45", features = ["serde"] }
serde = { version = "1.0.229", features = ["derive"] }
ts-rs = { version = "12.0.1", features = ["uuid-impl", "chrono-impl"] }
uuid = { version = "1.29.0", features = ["serde", "v7"] }
+17
View File
@@ -0,0 +1,17 @@
$schema: 'https://moonrepo.dev/schemas/project.json'
language: rust
tasks:
check:
command: cargo clippy -p archify-sync-protocol --all-targets -- -D warnings
inputs: ['src/**/*', 'Cargo.toml']
test:
command: cargo test -p archify-sync-protocol
inputs: ['src/**/*', 'Cargo.toml']
format:
command: cargo fmt -p archify-sync-protocol --check
inputs: ['src/**/*']
# Writes the TypeScript types to packages/protocol/src/generated.
types:
command: cargo test -p archify-sync-protocol export_bindings
inputs: ['src/**/*']
outputs: ['/packages/protocol/src/generated/**/*']
+205
View File
@@ -0,0 +1,205 @@
//! What the sync server and the extension agree on: the shape of a space's
//! CRDT document and the HTTP API's JSON. The types are exported to
//! TypeScript (`packages/protocol`) with `cargo test -p archify-sync-protocol`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
/// Version of the wire protocol. A client sends it as `?v=` on the WebSocket
/// and the server refuses a version it does not speak.
pub const PROTOCOL_VERSION: u32 = 1;
/// Root map of a space's Yjs document: model-root-relative POSIX path → `Y.Text`.
pub const FILES_MAP: &str = "files";
/// Error body of every failed request.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ApiError {
/// Stable code for clients (`unauthorized`, `not_found`, `invalid`, `conflict`, …).
pub error: String,
pub message: String,
}
// ---------------------------------------------------------------- accounts
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum OrgRole {
Admin,
Member,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum SpaceRole {
Owner,
Editor,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Account {
pub id: Uuid,
pub organisation_id: Uuid,
pub name: String,
pub display_name: String,
pub role: OrgRole,
}
/// `POST /v1/setup`: the first admin, with the code the server printed on its first start.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct SetupRequest {
pub code: String,
pub organisation: String,
pub name: String,
pub display_name: Option<String>,
pub password: String,
}
/// `POST /v1/auth/token`: the code from the browser sign-in, or a refresh token.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "grant_type", rename_all = "snake_case")]
#[ts(export)]
pub enum TokenRequest {
AuthorizationCode {
code: String,
code_verifier: String,
redirect_uri: String,
},
RefreshToken {
refresh_token: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TokenResponse {
pub access_token: String,
/// Seconds until the access token expires.
pub expires_in: u64,
/// Single use: every refresh returns a new one, and reusing an old one signs the session out.
pub refresh_token: String,
pub account: Account,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct LogoutRequest {
pub refresh_token: String,
}
// ------------------------------------------------------------------ spaces
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Space {
pub id: Uuid,
pub name: String,
pub role: SpaceRole,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CreateSpaceRequest {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Member {
pub account_id: Uuid,
pub name: String,
pub display_name: String,
pub role: SpaceRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CreateInviteRequest {
/// Hours until the link stops working; the server's default when absent.
pub expires_in_hours: Option<u32>,
pub max_uses: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Invite {
/// Shown once; the server keeps only its hash.
pub token: String,
/// The link to send: opens the server's join page.
pub url: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JoinResponse {
pub space: Space,
}
// ---------------------------------------------------------------- versions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum VersionKind {
/// Taken by the server after a pause in editing or when the last editor leaves.
Snapshot,
/// Marked by a person as an official version.
Publish,
/// A restore of an earlier version; history is never rewritten.
Restore,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Version {
pub id: i64,
pub major: i32,
pub minor: i32,
pub kind: VersionKind,
pub name: Option<String>,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
/// For a restore: the version it restored.
pub restored_from: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PublishRequest {
pub name: String,
}
/// The model files of a version as text, for comparing and showing.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VersionFiles {
pub version: Version,
pub files: Vec<VersionFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VersionFile {
pub path: String,
pub text: String,
}
/// `GET /v1/info`: what a client needs before signing in.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ServerInfo {
pub name: String,
pub version: String,
pub protocol: u32,
/// False until the first admin has used the first-start code.
pub set_up: bool,
}
+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,
})))
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "archify-sync-store"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
chrono = { version = "0.4.45", features = ["serde"] }
serde_json = "1.0.154"
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json", "migrate", "macros"] }
thiserror = "2.0.21"
tokio = { version = "1.54.1", features = ["rt"] }
uuid = { version = "1.29.0", features = ["serde", "v7"] }
+95
View File
@@ -0,0 +1,95 @@
-- Organisations, accounts and sessions.
CREATE TABLE organisations (
id uuid PRIMARY KEY,
name text NOT NULL,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE accounts (
id uuid PRIMARY KEY,
organisation_id uuid NOT NULL REFERENCES organisations (id) ON DELETE CASCADE,
name text NOT NULL, -- sign-in name, unique per server
display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('admin', 'member')),
password_hash text, -- argon2; NULL for SSO-only accounts
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX accounts_name ON accounts (lower(name));
-- Refresh tokens rotate on every use; a reused token revokes its family.
CREATE TABLE refresh_tokens (
token_hash bytea PRIMARY KEY,
family uuid NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX refresh_tokens_family ON refresh_tokens (family);
-- Server-wide values: the signing key, whether setup is done.
CREATE TABLE settings (
key text PRIMARY KEY,
value jsonb NOT NULL
);
-- Spaces: one shared model each.
CREATE TABLE spaces (
id uuid PRIMARY KEY,
organisation_id uuid NOT NULL REFERENCES organisations (id) ON DELETE CASCADE,
name text NOT NULL,
created_by uuid REFERENCES accounts (id) ON DELETE SET NULL,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE members (
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'editor')),
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (space_id, account_id)
);
CREATE INDEX members_account ON members (account_id);
CREATE TABLE invites (
token_hash bytea PRIMARY KEY,
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
created_by uuid REFERENCES accounts (id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
max_uses integer,
uses integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The CRDT: an append-only log of Yjs updates since the last version, and
-- versions as full encoded documents. Taking a version compacts the log.
CREATE TABLE updates (
id bigserial PRIMARY KEY,
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
data bytea NOT NULL,
account_id uuid REFERENCES accounts (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX updates_space ON updates (space_id, id);
CREATE TABLE versions (
id bigserial PRIMARY KEY,
space_id uuid NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
major integer NOT NULL,
minor integer NOT NULL,
kind text NOT NULL CHECK (kind IN ('snapshot', 'publish', 'restore')),
name text,
state bytea NOT NULL,
up_to bigint NOT NULL, -- last update id the state contains
restored_from bigint REFERENCES versions (id) ON DELETE SET NULL,
created_by uuid REFERENCES accounts (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, major, minor)
);
CREATE INDEX versions_space ON versions (space_id, id DESC);
@@ -0,0 +1,13 @@
-- Accounts signed in through an identity provider (OIDC): the provider's
-- issuer and subject name the person; the account is ours.
CREATE TABLE identities (
issuer text NOT NULL,
subject text NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
email text,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (issuer, subject)
);
CREATE INDEX identities_account ON identities (account_id);
+12
View File
@@ -0,0 +1,12 @@
$schema: 'https://moonrepo.dev/schemas/project.json'
language: rust
tasks:
check:
command: cargo clippy -p archify-sync-store --all-targets -- -D warnings
inputs: ['src/**/*', 'Cargo.toml']
test:
command: cargo test -p archify-sync-store
inputs: ['src/**/*', 'Cargo.toml']
format:
command: cargo fmt -p archify-sync-store --check
inputs: ['src/**/*']
+726
View File
@@ -0,0 +1,726 @@
//! PostgreSQL, the server's only state: accounts and sessions, spaces and
//! their members, the Yjs update log and the versions.
use chrono::{DateTime, Utc};
use sqlx::postgres::{PgPool, PgPoolOptions};
use sqlx::{FromRow, Postgres, Transaction};
use uuid::Uuid;
pub use sqlx::Error as SqlError;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error(transparent)]
Sql(#[from] sqlx::Error),
#[error(transparent)]
Migrate(#[from] sqlx::migrate::MigrateError),
#[error("{0} already exists")]
Conflict(&'static str),
}
pub type Result<T> = std::result::Result<T, StoreError>;
#[derive(Debug, Clone, FromRow)]
pub struct AccountRow {
pub id: Uuid,
pub organisation_id: Uuid,
pub name: String,
pub display_name: String,
pub role: String,
pub password_hash: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
pub struct SpaceRow {
pub id: Uuid,
pub organisation_id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
/// The asking account's role in the space.
pub role: String,
}
#[derive(Debug, Clone, FromRow)]
pub struct MemberRow {
pub account_id: Uuid,
pub name: String,
pub display_name: String,
pub role: String,
}
#[derive(Debug, Clone, FromRow)]
pub struct VersionRow {
pub id: i64,
pub major: i32,
pub minor: i32,
pub kind: String,
pub name: Option<String>,
pub up_to: i64,
pub restored_from: Option<i64>,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
/// What a space's document is made of: the latest version's state and the updates after it.
#[derive(Debug, Default)]
pub struct StoredDoc {
pub state: Option<Vec<u8>>,
pub updates: Vec<Vec<u8>>,
/// Id of the last update in the log, or the latest version's `up_to` when the log is empty.
pub last_update: i64,
pub latest: Option<VersionRow>,
}
/// family, account, expires_at, used_at
type RefreshRow = (Uuid, Uuid, DateTime<Utc>, bool);
pub enum RefreshOutcome {
Valid {
account: AccountRow,
family: Uuid,
},
/// Unknown or expired.
Invalid,
/// Used before: someone holds a copy. The whole family is revoked.
Reused,
}
pub enum InviteOutcome {
Joined { space_id: Uuid },
Invalid,
}
#[derive(Clone)]
pub struct Store {
pool: PgPool,
}
// A macro, not a const: sqlx takes only static SQL, which `concat!` keeps.
macro_rules! version_columns {
() => {
"id, major, minor, kind, name, up_to, restored_from, created_by, created_at"
};
}
impl Store {
pub async fn connect(url: &str, max_connections: u32) -> Result<Self> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(url)
.await?;
Ok(Self { pool })
}
pub async fn migrate(&self) -> Result<()> {
sqlx::migrate!("./migrations").run(&self.pool).await?;
Ok(())
}
pub async fn ping(&self) -> Result<()> {
sqlx::query("SELECT 1").execute(&self.pool).await?;
Ok(())
}
// ------------------------------------------------------------ settings
pub async fn setting(&self, key: &str) -> Result<Option<serde_json::Value>> {
Ok(
sqlx::query_scalar("SELECT value FROM settings WHERE key = $1")
.bind(key)
.fetch_optional(&self.pool)
.await?,
)
}
/// Stores the value unless the key exists; returns the value that is stored.
pub async fn setting_or_insert(
&self,
key: &str,
value: serde_json::Value,
) -> Result<serde_json::Value> {
sqlx::query(
"INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
)
.bind(key)
.bind(&value)
.execute(&self.pool)
.await?;
Ok(self.setting(key).await?.unwrap_or(value))
}
// ------------------------------------------------------------ accounts
pub async fn account_count(&self) -> Result<i64> {
Ok(sqlx::query_scalar("SELECT count(*) FROM accounts")
.fetch_one(&self.pool)
.await?)
}
/// The first organisation and its admin, only while there are no accounts.
pub async fn create_first_admin(
&self,
organisation: &str,
name: &str,
display_name: &str,
password_hash: &str,
) -> Result<Option<AccountRow>> {
let mut tx = self.pool.begin().await?;
// Serialises concurrent setup attempts.
sqlx::query("LOCK TABLE accounts IN EXCLUSIVE MODE")
.execute(&mut *tx)
.await?;
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM accounts")
.fetch_one(&mut *tx)
.await?;
if count > 0 {
return Ok(None);
}
let org = Uuid::now_v7();
sqlx::query("INSERT INTO organisations (id, name) VALUES ($1, $2)")
.bind(org)
.bind(organisation)
.execute(&mut *tx)
.await?;
let account = insert_account(
&mut tx,
org,
name,
display_name,
"admin",
Some(password_hash),
)
.await?;
tx.commit().await?;
Ok(Some(account))
}
pub async fn create_account(
&self,
organisation_id: Uuid,
name: &str,
display_name: &str,
password_hash: &str,
) -> Result<AccountRow> {
let mut tx = self.pool.begin().await?;
let account = insert_account(
&mut tx,
organisation_id,
name,
display_name,
"member",
Some(password_hash),
)
.await?;
tx.commit().await?;
Ok(account)
}
pub async fn account_by_name(&self, name: &str) -> Result<Option<AccountRow>> {
Ok(sqlx::query_as(
"SELECT id, organisation_id, name, display_name, role, password_hash FROM accounts WHERE lower(name) = lower($1)",
)
.bind(name)
.fetch_optional(&self.pool)
.await?)
}
pub async fn account(&self, id: Uuid) -> Result<Option<AccountRow>> {
Ok(sqlx::query_as(
"SELECT id, organisation_id, name, display_name, role, password_hash FROM accounts WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await?)
}
// ------------------------------------------------------ SSO identities
/// The account an identity provider's person signs in as, if any.
pub async fn account_by_identity(
&self,
issuer: &str,
subject: &str,
) -> Result<Option<AccountRow>> {
Ok(sqlx::query_as(
"UPDATE identities i SET last_seen_at = now() FROM accounts a
WHERE i.issuer = $1 AND i.subject = $2 AND a.id = i.account_id
RETURNING a.id, a.organisation_id, a.name, a.display_name, a.role, a.password_hash",
)
.bind(issuer)
.bind(subject)
.fetch_optional(&self.pool)
.await?)
}
/// A new account without a password, signed in through an identity
/// provider. Takes `name`, or `name-2`, `name-3`, … when it is taken: an
/// SSO person never takes over an existing account by its name.
#[allow(clippy::too_many_arguments)]
pub async fn create_sso_account(
&self,
organisation_id: Uuid,
name: &str,
display_name: &str,
role: &str,
issuer: &str,
subject: &str,
email: Option<&str>,
) -> Result<AccountRow> {
for n in 1..=20 {
let candidate = if n == 1 {
name.to_owned()
} else {
format!("{name}-{n}")
};
let mut tx = self.pool.begin().await?;
let account = match insert_account(
&mut tx,
organisation_id,
&candidate,
display_name,
role,
None,
)
.await
{
Ok(account) => account,
Err(StoreError::Conflict(_)) => continue,
Err(e) => return Err(e),
};
sqlx::query("INSERT INTO identities (issuer, subject, account_id, email) VALUES ($1, $2, $3, $4)")
.bind(issuer)
.bind(subject)
.bind(account.id)
.bind(email)
.execute(&mut *tx)
.await
.map_err(|e| match &e {
sqlx::Error::Database(d) if d.is_unique_violation() => StoreError::Conflict("identity"),
_ => StoreError::Sql(e),
})?;
tx.commit().await?;
return Ok(account);
}
Err(StoreError::Conflict("account"))
}
pub async fn set_role(&self, account: Uuid, role: &str) -> Result<()> {
sqlx::query("UPDATE accounts SET role = $2 WHERE id = $1")
.bind(account)
.bind(role)
.execute(&self.pool)
.await?;
Ok(())
}
/// The server's organisation: the one made at setup.
pub async fn first_organisation(&self) -> Result<Option<Uuid>> {
Ok(
sqlx::query_scalar("SELECT id FROM organisations ORDER BY created_at LIMIT 1")
.fetch_optional(&self.pool)
.await?,
)
}
// ------------------------------------------------------ refresh tokens
pub async fn insert_refresh(
&self,
hash: &[u8],
family: Uuid,
account: Uuid,
expires_at: DateTime<Utc>,
) -> Result<()> {
sqlx::query("INSERT INTO refresh_tokens (token_hash, family, account_id, expires_at) VALUES ($1, $2, $3, $4)")
.bind(hash)
.bind(family)
.bind(account)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
/// Marks the token used. A token used again later than `grace_secs` after its
/// first use revokes every token of its family; within the grace, two windows
/// of the same editor that refreshed at the same moment both get through.
pub async fn use_refresh(&self, hash: &[u8], grace_secs: u32) -> Result<RefreshOutcome> {
let mut tx = self.pool.begin().await?;
let row: Option<RefreshRow> = sqlx::query_as(
"SELECT family, account_id, expires_at,
used_at IS NOT NULL AND used_at < now() - make_interval(secs => $2)
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
)
.bind(hash)
.bind(f64::from(grace_secs))
.fetch_optional(&mut *tx)
.await?;
let Some((family, account_id, expires_at, reused)) = row else {
return Ok(RefreshOutcome::Invalid);
};
if reused {
sqlx::query("DELETE FROM refresh_tokens WHERE family = $1")
.bind(family)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(RefreshOutcome::Reused);
}
if expires_at < Utc::now() {
return Ok(RefreshOutcome::Invalid);
}
sqlx::query(
"UPDATE refresh_tokens SET used_at = coalesce(used_at, now()) WHERE token_hash = $1",
)
.bind(hash)
.execute(&mut *tx)
.await?;
let account: Option<AccountRow> = sqlx::query_as(
"SELECT id, organisation_id, name, display_name, role, password_hash FROM accounts WHERE id = $1",
)
.bind(account_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
Ok(match account {
Some(account) => RefreshOutcome::Valid { account, family },
None => RefreshOutcome::Invalid,
})
}
/// Signs out the session the token belongs to.
pub async fn revoke_refresh(&self, hash: &[u8]) -> Result<()> {
sqlx::query("DELETE FROM refresh_tokens WHERE family = (SELECT family FROM refresh_tokens WHERE token_hash = $1)")
.bind(hash)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_expired_refresh(&self) -> Result<u64> {
// Used tokens are kept until expiry so a reuse is still recognised.
let done = sqlx::query("DELETE FROM refresh_tokens WHERE expires_at < now()")
.execute(&self.pool)
.await?;
Ok(done.rows_affected())
}
// -------------------------------------------------------------- spaces
pub async fn create_space(
&self,
organisation_id: Uuid,
name: &str,
owner: Uuid,
) -> Result<SpaceRow> {
let mut tx = self.pool.begin().await?;
let id = Uuid::now_v7();
let created_at: DateTime<Utc> = sqlx::query_scalar(
"INSERT INTO spaces (id, organisation_id, name, created_by) VALUES ($1, $2, $3, $4) RETURNING created_at",
)
.bind(id)
.bind(organisation_id)
.bind(name)
.bind(owner)
.fetch_one(&mut *tx)
.await?;
sqlx::query("INSERT INTO members (space_id, account_id, role) VALUES ($1, $2, 'owner')")
.bind(id)
.bind(owner)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(SpaceRow {
id,
organisation_id,
name: name.to_owned(),
created_at,
role: "owner".into(),
})
}
pub async fn spaces_of(&self, account: Uuid) -> Result<Vec<SpaceRow>> {
Ok(sqlx::query_as(
"SELECT s.id, s.organisation_id, s.name, s.created_at, m.role
FROM spaces s JOIN members m ON m.space_id = s.id
WHERE m.account_id = $1 ORDER BY s.name",
)
.bind(account)
.fetch_all(&self.pool)
.await?)
}
/// The space as the account sees it; None when it is not a member.
pub async fn space_for(&self, space: Uuid, account: Uuid) -> Result<Option<SpaceRow>> {
Ok(sqlx::query_as(
"SELECT s.id, s.organisation_id, s.name, s.created_at, m.role
FROM spaces s JOIN members m ON m.space_id = s.id
WHERE s.id = $1 AND m.account_id = $2",
)
.bind(space)
.bind(account)
.fetch_optional(&self.pool)
.await?)
}
pub async fn members(&self, space: Uuid) -> Result<Vec<MemberRow>> {
Ok(sqlx::query_as(
"SELECT a.id AS account_id, a.name, a.display_name, m.role
FROM members m JOIN accounts a ON a.id = m.account_id
WHERE m.space_id = $1 ORDER BY a.display_name",
)
.bind(space)
.fetch_all(&self.pool)
.await?)
}
pub async fn remove_member(&self, space: Uuid, account: Uuid) -> Result<bool> {
let done = sqlx::query(
"DELETE FROM members WHERE space_id = $1 AND account_id = $2 AND role <> 'owner'",
)
.bind(space)
.bind(account)
.execute(&self.pool)
.await?;
Ok(done.rows_affected() > 0)
}
// ------------------------------------------------------------- invites
pub async fn create_invite(
&self,
hash: &[u8],
space: Uuid,
created_by: Uuid,
expires_at: DateTime<Utc>,
max_uses: Option<i32>,
) -> Result<()> {
sqlx::query("INSERT INTO invites (token_hash, space_id, created_by, expires_at, max_uses) VALUES ($1, $2, $3, $4, $5)")
.bind(hash)
.bind(space)
.bind(created_by)
.bind(expires_at)
.bind(max_uses)
.execute(&self.pool)
.await?;
Ok(())
}
/// The organisation an invite belongs to, if it is still usable; for the join page.
pub async fn invite_organisation(&self, hash: &[u8]) -> Result<Option<(Uuid, String)>> {
Ok(sqlx::query_as(
"SELECT s.organisation_id, s.name FROM invites i JOIN spaces s ON s.id = i.space_id
WHERE i.token_hash = $1 AND i.expires_at > now() AND (i.max_uses IS NULL OR i.uses < i.max_uses)",
)
.bind(hash)
.fetch_optional(&self.pool)
.await?)
}
/// Adds the account to the invite's space as an editor. Accounts of other
/// organisations cannot join.
pub async fn accept_invite(&self, hash: &[u8], account: &AccountRow) -> Result<InviteOutcome> {
let mut tx = self.pool.begin().await?;
// (space, organisation, still usable, already a member)
let row: Option<(Uuid, Uuid, bool, bool)> = sqlx::query_as(
"SELECT i.space_id, s.organisation_id,
i.expires_at > now() AND (i.max_uses IS NULL OR i.uses < i.max_uses),
EXISTS (SELECT 1 FROM members m WHERE m.space_id = i.space_id AND m.account_id = $2)
FROM invites i JOIN spaces s ON s.id = i.space_id
WHERE i.token_hash = $1
FOR UPDATE OF i",
)
.bind(hash)
.bind(account.id)
.fetch_optional(&mut *tx)
.await?;
let Some((space_id, organisation_id, usable, member)) = row else {
return Ok(InviteOutcome::Invalid);
};
// Signing up through the invite already joined: accepting again only says where.
if member {
return Ok(InviteOutcome::Joined { space_id });
}
if !usable || organisation_id != account.organisation_id {
return Ok(InviteOutcome::Invalid);
}
let added = sqlx::query(
"INSERT INTO members (space_id, account_id, role) VALUES ($1, $2, 'editor') ON CONFLICT DO NOTHING",
)
.bind(space_id)
.bind(account.id)
.execute(&mut *tx)
.await?;
if added.rows_affected() > 0 {
sqlx::query("UPDATE invites SET uses = uses + 1 WHERE token_hash = $1")
.bind(hash)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(InviteOutcome::Joined { space_id })
}
// ------------------------------------------------------------ the CRDT
/// Appends one Yjs update; it is durable when this returns.
pub async fn append_update(
&self,
space: Uuid,
data: &[u8],
account: Option<Uuid>,
) -> Result<i64> {
Ok(sqlx::query_scalar(
"INSERT INTO updates (space_id, data, account_id) VALUES ($1, $2, $3) RETURNING id",
)
.bind(space)
.bind(data)
.bind(account)
.fetch_one(&self.pool)
.await?)
}
pub async fn load_doc(&self, space: Uuid) -> Result<StoredDoc> {
let latest = self.latest_version(space).await?;
let state: Option<Vec<u8>> = match &latest {
Some(v) => {
sqlx::query_scalar("SELECT state FROM versions WHERE id = $1")
.bind(v.id)
.fetch_optional(&self.pool)
.await?
}
None => None,
};
let from = latest.as_ref().map_or(0, |v| v.up_to);
let rows: Vec<(i64, Vec<u8>)> = sqlx::query_as(
"SELECT id, data FROM updates WHERE space_id = $1 AND id > $2 ORDER BY id",
)
.bind(space)
.bind(from)
.fetch_all(&self.pool)
.await?;
let last_update = rows.last().map_or(from, |(id, _)| *id);
Ok(StoredDoc {
state,
updates: rows.into_iter().map(|(_, d)| d).collect(),
last_update,
latest,
})
}
pub async fn latest_version(&self, space: Uuid) -> Result<Option<VersionRow>> {
Ok(sqlx::query_as(concat!(
"SELECT ",
version_columns!(),
" FROM versions WHERE space_id = $1 ORDER BY id DESC LIMIT 1"
))
.bind(space)
.fetch_optional(&self.pool)
.await?)
}
/// Stores a version and drops the updates it contains from the log.
#[allow(clippy::too_many_arguments)]
pub async fn insert_version(
&self,
space: Uuid,
major: i32,
minor: i32,
kind: &str,
name: Option<&str>,
state: &[u8],
up_to: i64,
restored_from: Option<i64>,
created_by: Option<Uuid>,
) -> Result<VersionRow> {
let mut tx = self.pool.begin().await?;
let row: VersionRow = sqlx::query_as(concat!("INSERT INTO versions (space_id, major, minor, kind, name, state, up_to, restored_from, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING ", version_columns!(), ""))
.bind(space)
.bind(major)
.bind(minor)
.bind(kind)
.bind(name)
.bind(state)
.bind(up_to)
.bind(restored_from)
.bind(created_by)
.fetch_one(&mut *tx)
.await
.map_err(|e| match &e {
sqlx::Error::Database(d) if d.is_unique_violation() => StoreError::Conflict("version"),
_ => StoreError::Sql(e),
})?;
sqlx::query("DELETE FROM updates WHERE space_id = $1 AND id <= $2")
.bind(space)
.bind(up_to)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(row)
}
pub async fn versions(&self, space: Uuid, limit: i64) -> Result<Vec<VersionRow>> {
Ok(sqlx::query_as(concat!(
"SELECT ",
version_columns!(),
" FROM versions WHERE space_id = $1 ORDER BY id DESC LIMIT $2"
))
.bind(space)
.bind(limit)
.fetch_all(&self.pool)
.await?)
}
pub async fn version(&self, space: Uuid, id: i64) -> Result<Option<(VersionRow, Vec<u8>)>> {
let row: Option<VersionRow> = sqlx::query_as(concat!(
"SELECT ",
version_columns!(),
" FROM versions WHERE space_id = $1 AND id = $2"
))
.bind(space)
.bind(id)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else { return Ok(None) };
let state: Vec<u8> = sqlx::query_scalar("SELECT state FROM versions WHERE id = $1")
.bind(id)
.fetch_one(&self.pool)
.await?;
Ok(Some((row, state)))
}
}
async fn insert_account(
tx: &mut Transaction<'_, Postgres>,
organisation_id: Uuid,
name: &str,
display_name: &str,
role: &str,
password_hash: Option<&str>,
) -> Result<AccountRow> {
let id = Uuid::now_v7();
sqlx::query(
"INSERT INTO accounts (id, organisation_id, name, display_name, role, password_hash) VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(id)
.bind(organisation_id)
.bind(name)
.bind(display_name)
.bind(role)
.bind(password_hash)
.execute(&mut **tx)
.await
.map_err(|e| match &e {
sqlx::Error::Database(d) if d.is_unique_violation() => StoreError::Conflict("account"),
_ => StoreError::Sql(e),
})?;
Ok(AccountRow {
id,
organisation_id,
name: name.to_owned(),
display_name: display_name.to_owned(),
role: role.to_owned(),
password_hash: password_hash.map(str::to_owned),
})
}