mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
* feat(ai-sessions): share session artifacts with the workspace by link Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * chore: cache the shared artifact queries for offline sqlx Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix: replace a literal NUL byte in the shared artifact body limit comment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * test: pin that a shared artifact is confined to its workspace's path Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix: sanitize shared artifact markdown and validate the artifact id on every route The shared page renders another member's markdown, so ArtifactBody now runs the repo's rehype-raw + rehype-sanitize chain with the chat's link renderer on top; only the session viewer opts into the chat code block (mermaid, apply button). The link renderer keeps a link's text when its href is empty or unsafe, and the scheme check moves to a tested helper. The status route checks artifact_id like share does, so a NUL is a 400 rather than a 500. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix(ai-sessions): say which way re-sharing moves an artifact link The popover offered "Update to v1" when a v2 link was open on a pinned v1, which reads as if v1 were newer. Each direction now has its own sentence and action: a newer version on screen updates the link, an older one shares that version instead, a rename updates the name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
323 lines
9.6 KiB
Rust
323 lines
9.6 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2026
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
//! Read-only copies of AI session artifacts, shared with the workspace by their author.
|
|
//!
|
|
//! Artifacts live in the author's browser; a row here exists only because the author asked
|
|
//! for a link. Every read filters on the retention window as well as the monitor sweeping
|
|
//! it, so a share past its window is never served in the gap before the sweep reaches it.
|
|
|
|
use crate::db::{ApiAuthed, DB};
|
|
use axum::{
|
|
extract::{DefaultBodyLimit, Extension, Json, Path, Query},
|
|
routing::{delete, get, post},
|
|
Router,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
|
use windmill_common::{
|
|
ai_shared_artifact_retention_secs,
|
|
error::{Error, JsonResult, Result},
|
|
};
|
|
|
|
/// The frontend's `MAX_ARTIFACT_BYTES`: no artifact the viewer holds is larger.
|
|
const MAX_CONTENT_BYTES: usize = 256 * 1024;
|
|
const MAX_NAME_CHARS: usize = 255;
|
|
const MAX_ARTIFACT_ID_CHARS: usize = 255;
|
|
/// JSON escapes a control character into six bytes, so a full-size artifact made entirely of
|
|
/// them still fits.
|
|
const SHARE_BODY_LIMIT: usize = MAX_CONTENT_BYTES * 6 + 64 * 1024;
|
|
|
|
pub fn workspaced_service() -> Router {
|
|
Router::new()
|
|
.route(
|
|
"/share",
|
|
post(share_artifact).layer(DefaultBodyLimit::max(SHARE_BODY_LIMIT)),
|
|
)
|
|
.route("/status", get(get_share_status))
|
|
.route("/get/{id}", get(get_shared_artifact))
|
|
.route("/delete/{id}", delete(unshare_artifact))
|
|
}
|
|
|
|
#[derive(Deserialize, Clone, Copy)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum ArtifactKind {
|
|
Md,
|
|
Html,
|
|
}
|
|
|
|
impl ArtifactKind {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
ArtifactKind::Md => "md",
|
|
ArtifactKind::Html => "html",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ShareArtifact {
|
|
artifact_id: String,
|
|
name: String,
|
|
kind: ArtifactKind,
|
|
version: i32,
|
|
content: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SharedArtifactInfo {
|
|
id: Uuid,
|
|
name: String,
|
|
kind: String,
|
|
version: i32,
|
|
created_by: String,
|
|
shared_at: DateTime<Utc>,
|
|
expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SharedArtifact {
|
|
#[serde(flatten)]
|
|
info: SharedArtifactInfo,
|
|
content: String,
|
|
/// Whether the caller may stop sharing it: its author, or a workspace admin.
|
|
can_unshare: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ShareStatus {
|
|
retention_secs: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
share: Option<SharedArtifactInfo>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ShareStatusQuery {
|
|
artifact_id: String,
|
|
}
|
|
|
|
fn expires_at(shared_at: DateTime<Utc>, retention_secs: i64) -> DateTime<Utc> {
|
|
shared_at + chrono::Duration::seconds(retention_secs)
|
|
}
|
|
|
|
/// The browser-side artifact id, as every handler that takes one must check it: it is compared
|
|
/// against a `VARCHAR(255)` column, and Postgres answers a NUL in a text parameter with an
|
|
/// opaque 500.
|
|
fn check_artifact_id(artifact_id: &str) -> Result<()> {
|
|
if artifact_id.is_empty() || artifact_id.chars().count() > MAX_ARTIFACT_ID_CHARS {
|
|
return Err(Error::BadRequest(format!(
|
|
"Artifact id must be between 1 and {MAX_ARTIFACT_ID_CHARS} characters"
|
|
)));
|
|
}
|
|
if artifact_id.contains('\0') {
|
|
return Err(Error::BadRequest(
|
|
"Artifact id cannot contain NUL characters".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Share an artifact, or move the caller's existing link for it to this content. Re-sharing
|
|
/// keeps the link and restarts its retention window.
|
|
async fn share_artifact(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Json(payload): Json<ShareArtifact>,
|
|
) -> JsonResult<SharedArtifactInfo> {
|
|
let name = payload.name.trim();
|
|
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
|
|
return Err(Error::BadRequest(format!(
|
|
"Artifact name must be between 1 and {MAX_NAME_CHARS} characters"
|
|
)));
|
|
}
|
|
check_artifact_id(&payload.artifact_id)?;
|
|
if payload.content.len() > MAX_CONTENT_BYTES {
|
|
return Err(Error::BadRequest(format!(
|
|
"Artifact content is {} bytes, above the {MAX_CONTENT_BYTES} byte limit",
|
|
payload.content.len()
|
|
)));
|
|
}
|
|
if payload.version < 1 {
|
|
return Err(Error::BadRequest(
|
|
"Artifact version must be at least 1".to_string(),
|
|
));
|
|
}
|
|
// Postgres rejects NUL in text columns with an opaque 500.
|
|
if name.contains('\0') || payload.content.contains('\0') {
|
|
return Err(Error::BadRequest(
|
|
"Artifact name and content cannot contain NUL characters".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut tx = db.begin().await?;
|
|
let row = sqlx::query!(
|
|
r#"INSERT INTO ai_shared_artifact
|
|
(workspace_id, artifact_id, email, created_by, name, kind, version, content)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (workspace_id, email, artifact_id) DO UPDATE
|
|
SET created_by = EXCLUDED.created_by,
|
|
name = EXCLUDED.name,
|
|
kind = EXCLUDED.kind,
|
|
version = EXCLUDED.version,
|
|
content = EXCLUDED.content,
|
|
shared_at = now()
|
|
RETURNING id, shared_at, (xmax = 0) AS "inserted!""#,
|
|
&w_id,
|
|
&payload.artifact_id,
|
|
&authed.email,
|
|
&authed.username,
|
|
name,
|
|
payload.kind.as_str(),
|
|
payload.version,
|
|
&payload.content,
|
|
)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
|
|
let id = row.id.to_string();
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
"ai.shared_artifacts.share",
|
|
if row.inserted {
|
|
ActionKind::Create
|
|
} else {
|
|
ActionKind::Update
|
|
},
|
|
&w_id,
|
|
Some(&id),
|
|
Some([("name", name)].into()),
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
Ok(Json(SharedArtifactInfo {
|
|
id: row.id,
|
|
name: name.to_string(),
|
|
kind: payload.kind.as_str().to_string(),
|
|
version: payload.version,
|
|
created_by: authed.username.clone(),
|
|
shared_at: row.shared_at,
|
|
expires_at: expires_at(row.shared_at, ai_shared_artifact_retention_secs()),
|
|
}))
|
|
}
|
|
|
|
/// The caller's own live share of one of their artifacts, if any, and how long a share lasts.
|
|
async fn get_share_status(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Query(query): Query<ShareStatusQuery>,
|
|
) -> JsonResult<ShareStatus> {
|
|
check_artifact_id(&query.artifact_id)?;
|
|
let retention_secs = ai_shared_artifact_retention_secs();
|
|
let share = sqlx::query!(
|
|
"SELECT id, name, kind, version, created_by, shared_at FROM ai_shared_artifact
|
|
WHERE workspace_id = $1 AND email = $2 AND artifact_id = $3
|
|
AND shared_at > now() - ($4::bigint::text || ' s')::interval",
|
|
&w_id,
|
|
&authed.email,
|
|
&query.artifact_id,
|
|
retention_secs,
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.map(|r| SharedArtifactInfo {
|
|
id: r.id,
|
|
name: r.name,
|
|
kind: r.kind,
|
|
version: r.version,
|
|
created_by: r.created_by,
|
|
shared_at: r.shared_at,
|
|
expires_at: expires_at(r.shared_at, retention_secs),
|
|
});
|
|
|
|
Ok(Json(ShareStatus { retention_secs, share }))
|
|
}
|
|
|
|
/// Any member of the workspace may read a live share: that is what sharing it granted.
|
|
async fn get_shared_artifact(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, id)): Path<(String, Uuid)>,
|
|
) -> JsonResult<SharedArtifact> {
|
|
let retention_secs = ai_shared_artifact_retention_secs();
|
|
let row = sqlx::query!(
|
|
"SELECT id, email, name, kind, version, created_by, content, shared_at
|
|
FROM ai_shared_artifact
|
|
WHERE workspace_id = $1 AND id = $2
|
|
AND shared_at > now() - ($3::bigint::text || ' s')::interval",
|
|
&w_id,
|
|
id,
|
|
retention_secs,
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::NotFound(format!(
|
|
"Shared artifact {id} not found: it may have expired or been unshared"
|
|
))
|
|
})?;
|
|
|
|
Ok(Json(SharedArtifact {
|
|
can_unshare: row.email == authed.email || authed.is_admin,
|
|
content: row.content,
|
|
info: SharedArtifactInfo {
|
|
id: row.id,
|
|
name: row.name,
|
|
kind: row.kind,
|
|
version: row.version,
|
|
created_by: row.created_by,
|
|
shared_at: row.shared_at,
|
|
expires_at: expires_at(row.shared_at, retention_secs),
|
|
},
|
|
}))
|
|
}
|
|
|
|
async fn unshare_artifact(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, id)): Path<(String, Uuid)>,
|
|
) -> Result<String> {
|
|
let mut tx = db.begin().await?;
|
|
let name = sqlx::query_scalar!(
|
|
"DELETE FROM ai_shared_artifact
|
|
WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool)
|
|
RETURNING name",
|
|
&w_id,
|
|
id,
|
|
&authed.email,
|
|
authed.is_admin,
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::NotFound(format!(
|
|
"Shared artifact {id} not found, or not shared by you"
|
|
))
|
|
})?;
|
|
|
|
let id_str = id.to_string();
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
"ai.shared_artifacts.unshare",
|
|
ActionKind::Delete,
|
|
&w_id,
|
|
Some(&id_str),
|
|
Some([("name", name.as_str())].into()),
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
Ok(format!("Stopped sharing {name}"))
|
|
}
|