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:
commit
5c43b90ef8
67 files changed
+8362
No files matched your search
@@ -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"] }
|
||||
@@ -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/**/*']
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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]
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
@@ -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/**/*']
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user