forked from yvv/decision
v3 : points de la relecture indépendante — une personne une voix, ordre de déploiement, durcissements
- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la toile votent (trustin_access::votes_in) ; une identité d'invitation y lit, propose et signale. L'interface le dit à la place des boutons (fr/en/es). - Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé avant de créer l'identité (deux acceptations simultanées : une seule passe). - Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »). - Redirection après connexion : caractères de contrôle refusés. - En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy. - Déploiement : le produit passe en premier ; la maquette attend son succès. Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de « restart: always » (ils reviendraient au redémarrage de Docker). - Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,8 +129,12 @@ pub async fn invite(
|
||||
if label.is_empty() || label.chars().count() > 60 {
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
if let Some(id) = &b.identity_id {
|
||||
auth::load_identity(&state, id).await?;
|
||||
// A new link for an existing person is for invitation identities only: a link
|
||||
// to a Ğ1 identity would let an administrator open a session in its name.
|
||||
if let Some(id) = &b.identity_id
|
||||
&& auth::load_identity(&state, id).await?.kind != "invite"
|
||||
{
|
||||
return Err(AppError::Invalid("identity_kind"));
|
||||
}
|
||||
let token =
|
||||
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
||||
|
||||
@@ -169,7 +169,16 @@ pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
|
||||
/// Only local paths, never `//host` nor a scheme.
|
||||
fn safe_return(raw: Option<&str>) -> String {
|
||||
match raw {
|
||||
Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && p.len() < 512 => p.to_owned(),
|
||||
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
|
||||
Some(p)
|
||||
if p.starts_with('/')
|
||||
&& !p.starts_with("//")
|
||||
&& !p.contains('\\')
|
||||
&& !p.chars().any(char::is_control)
|
||||
&& p.len() < 512 =>
|
||||
{
|
||||
p.to_owned()
|
||||
}
|
||||
_ => "/groupes".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -324,18 +333,33 @@ struct InvitationRow {
|
||||
|
||||
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
||||
let hash = util::sha256_hex(body.token.trim());
|
||||
let now = util::now();
|
||||
// Reserve the link first: of two simultaneous acceptances, one only goes on.
|
||||
let reserved =
|
||||
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
|
||||
.bind(&now)
|
||||
.bind(&hash)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if reserved.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
let inv = sqlx::query_as::<_, InvitationRow>(
|
||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations
|
||||
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
|
||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
|
||||
)
|
||||
.bind(&hash)
|
||||
.bind(util::now())
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
||||
|
||||
let identity_id = match inv.identity_id.clone() {
|
||||
Some(existing) => existing,
|
||||
Some(existing) => {
|
||||
if load_identity(&state, &existing).await?.kind != "invite" {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
existing
|
||||
}
|
||||
None => {
|
||||
let id = format!("inv:{}", util::id());
|
||||
sqlx::query(
|
||||
@@ -355,16 +379,11 @@ pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<I
|
||||
}
|
||||
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
|
||||
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
|
||||
let used =
|
||||
sqlx::query("UPDATE invitations SET used_at = ?, identity_id = ? WHERE token_hash = ? AND used_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if used.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let session = open_session(&state, &identity_id).await?;
|
||||
let me = load_identity(&state, &identity_id).await?;
|
||||
let mut res = Json(json!({ "me": me })).into_response();
|
||||
|
||||
@@ -69,6 +69,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||
let has = granted(state, &group.slug, &user.id).await?;
|
||||
Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GroupView {
|
||||
@@ -523,7 +529,9 @@ pub async fn document(
|
||||
.collect();
|
||||
|
||||
let publication = publication_status(&state, &id).await?;
|
||||
let can_vote = may_vote(&state, &user, &st.group).await?;
|
||||
Ok(Json(json!({
|
||||
"canVote": can_vote,
|
||||
"doc": {
|
||||
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||
|
||||
@@ -123,9 +123,20 @@ pub fn api_router() -> Router<AppState> {
|
||||
}
|
||||
|
||||
pub fn app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.nest("/api", api_router())
|
||||
.fallback_service(web::service(&state.cfg.web_dir))
|
||||
use axum::http::{HeaderName, HeaderValue, header};
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
// Neighbouring sites under *.librodrome.org are same-site: none may frame us.
|
||||
let security: [(HeaderName, &'static str); 4] = [
|
||||
(header::X_FRAME_OPTIONS, "DENY"),
|
||||
(header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
(header::REFERRER_POLICY, "same-origin"),
|
||||
];
|
||||
let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir));
|
||||
for (name, value) in security {
|
||||
router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)));
|
||||
}
|
||||
router
|
||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
|
||||
.layer(tower_http::compression::CompressionLayer::new())
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
|
||||
@@ -594,6 +594,10 @@ pub async fn vote(
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
|
||||
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
|
||||
return Err(AppError::Conflict("wot_members_only"));
|
||||
}
|
||||
match b.choice.as_deref() {
|
||||
Some(choice @ ("for" | "against")) => {
|
||||
sqlx::query(
|
||||
|
||||
Reference in New Issue
Block a user