feat(ai-sessions): share session artifacts with the workspace by link (#11115)

* 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>
This commit is contained in:
Ruben Fiszel
2026-09-14 22:46:26 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent e8c02c04cd
commit 57a134e2de
28 changed files with 1527 additions and 65 deletions
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_shared_artifact\n (workspace_id, artifact_id, email, created_by, name, kind, version, content)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (workspace_id, email, artifact_id) DO UPDATE\n SET created_by = EXCLUDED.created_by,\n name = EXCLUDED.name,\n kind = EXCLUDED.kind,\n version = EXCLUDED.version,\n content = EXCLUDED.content,\n shared_at = now()\n RETURNING id, shared_at, (xmax = 0) AS \"inserted!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "shared_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "inserted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Int4",
"Text"
]
},
"nullable": [
false,
false,
null
]
},
"hash": "0589cb0f96e17ecadae4923be70a6cf0148a9e10c1c4ad0f99312b8e99ec1b8a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_shared_artifact\n WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "6774bc0ec8ca8c6c48e8e111ab074b8c5beb1c3992f6413d7bcabc8159c0f9bb"
}
@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, email, name, kind, version, created_by, content, shared_at\n FROM ai_shared_artifact\n WHERE workspace_id = $1 AND id = $2\n AND shared_at > now() - ($3::bigint::text || ' s')::interval",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "kind",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "version",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "shared_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "945230149990abda67fdf4779529207306fb19756ab9c3a3d7824a15542a5b42"
}
@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, version, created_by, shared_at FROM ai_shared_artifact\n WHERE workspace_id = $1 AND email = $2 AND artifact_id = $3\n AND shared_at > now() - ($4::bigint::text || ' s')::interval",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "version",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "shared_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "d2861932a739887785658cdf89a804306fe2083928e57458ba936d6afde53b57"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_shared_artifact\n WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool)\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "e50660f58274e9c135ace356ea8107739baa9ee276a99f481335fec8099f4d51"
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_shared_artifact;
@@ -0,0 +1,32 @@
-- A copy of an AI session artifact that its author explicitly shared with the workspace.
-- Artifacts otherwise live only in the author's browser; this row exists only while the
-- share does, and the monitor deletes it once `shared_at` falls outside
-- AI_SHARED_ARTIFACT_RETENTION_SECS.
CREATE TABLE ai_shared_artifact (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
-- The browser-side artifact id. Unique per author so sharing the same artifact again
-- moves its one link forward rather than minting a second one.
artifact_id VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_by VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
kind VARCHAR(10) NOT NULL CHECK (kind IN ('md', 'html')),
version INTEGER NOT NULL,
content TEXT NOT NULL,
-- Reset on every re-share: retention counts from the last time the author shared it.
shared_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, email, artifact_id)
);
CREATE INDEX idx_ai_shared_artifact_shared_at ON ai_shared_artifact (shared_at);
GRANT ALL ON ai_shared_artifact TO windmill_admin;
GRANT ALL ON ai_shared_artifact TO windmill_user;
-- The handlers go through the raw pool and scope every query to the workspace themselves.
-- An admin-only policy is the backstop for a future query that reaches this table through
-- UserDB.
ALTER TABLE ai_shared_artifact ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_policy ON ai_shared_artifact FOR ALL TO windmill_admin USING (true);
+11
View File
@@ -1912,6 +1912,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::info!("deleted {} expired otel trace spans", deleted_spans);
}
if let Err(e) = sqlx::query!(
"DELETE FROM ai_shared_artifact
WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
windmill_common::ai_shared_artifact_retention_secs(),
)
.execute(db)
.await
{
tracing::error!("Error deleting expired shared AI artifacts: {:?}", e);
}
let audit_retention_days = audit_log_retention_days().await;
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
+2
View File
@@ -40,6 +40,8 @@ agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklis
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
ai_shared_artifact: id(uuid), workspace_id(char), artifact_id(char), email(char), created_by(char), name(char), kind(char), version(int), content(text), shared_at(ts)
FK: (workspace_id) -> workspace(id)
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
FK: (workspace_id) -> workspace(id)
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
+184
View File
@@ -0,0 +1,184 @@
//! Shared AI session artifacts: one link per author and artifact, readable by any workspace
//! member until its retention window passes, and removable only by its author or an admin.
//!
//! Expiry is enforced on read as well as by the monitor's sweep, so a share past its window must
//! not be served in the gap before the sweep reaches it.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN: &str = "Bearer SECRET_TOKEN";
const MEMBER: &str = "Bearer SECRET_TOKEN_2";
async fn share(
client: &reqwest::Client,
base: &str,
token: &str,
content: &str,
) -> anyhow::Result<Value> {
let resp = client
.post(format!("{base}/share"))
.header("Authorization", token)
.json(&json!({
"artifact_id": "plan:session-1",
"name": "Plan",
"kind": "md",
"version": 1,
"content": content,
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(resp.json().await?)
}
#[sqlx::test(fixtures("base", "ai_shared_artifacts"))]
async fn shared_artifact_is_served_to_members_until_it_expires(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
let first = share(&client, &base, MEMBER, "draft").await?;
let second = share(&client, &base, MEMBER, "final").await?;
assert_eq!(first["id"], second["id"], "re-sharing minted a second link");
let id = second["id"].as_str().unwrap();
let resp = client
.get(format!("{base}/get/{id}"))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["content"], "final");
// The handlers read through the raw pool, so the workspace in the URL is the only thing
// scoping a share: a member of another workspace must not reach it by id through theirs.
let resp = client
.get(format!(
"http://localhost:{}/api/w/test-workspace-2/ai/shared_artifacts/get/{id}",
server.addr.port()
))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(
resp.status(),
404,
"a share was served through another workspace's path"
);
sqlx::query(
"UPDATE ai_shared_artifact SET shared_at = now() - ($1::bigint + 60) * interval '1 second'",
)
.bind(windmill_common::ai_shared_artifact_retention_secs())
.execute(&db)
.await?;
let resp = client
.get(format!("{base}/get/{id}"))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 404, "an expired share was served");
let status: Value = client
.get(format!("{base}/status?artifact_id=plan:session-1"))
.header("Authorization", MEMBER)
.send()
.await?
.json()
.await?;
assert!(
status.get("share").is_none(),
"an expired share was reported live: {status}"
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn only_the_author_or_an_admin_can_unshare(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
let shared = share(&client, &base, ADMIN, "admin's plan").await?;
let id = shared["id"].as_str().unwrap();
let resp = client
.delete(format!("{base}/delete/{id}"))
.header("Authorization", MEMBER)
.send()
.await?;
assert_eq!(resp.status(), 404);
let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM ai_shared_artifact")
.fetch_one(&db)
.await?;
assert_eq!(remaining, 1, "a member deleted someone else's share");
let member_share = share(&client, &base, MEMBER, "member's plan").await?;
let resp = client
.delete(format!(
"{base}/delete/{}",
member_share["id"].as_str().unwrap()
))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
/// The id is compared against a `VARCHAR(255)` column on every route that takes one, and a
/// NUL in it would otherwise reach Postgres and come back as a 500.
#[sqlx::test(fixtures("base"))]
async fn a_malformed_artifact_id_is_refused_on_every_route(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
for bad_id in ["", "a\0b", &"x".repeat(256)] {
let resp = client
.get(format!("{base}/status"))
.query(&[("artifact_id", bad_id)])
.header("Authorization", MEMBER)
.send()
.await?;
assert_eq!(resp.status(), 400, "status accepted {bad_id:?}");
let resp = client
.post(format!("{base}/share"))
.header("Authorization", MEMBER)
.json(&json!({
"artifact_id": bad_id,
"name": "Plan",
"kind": "md",
"version": 1,
"content": "x",
}))
.send()
.await?;
assert_eq!(resp.status(), 400, "share accepted {bad_id:?}");
}
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
-- Layers on `base`: a second workspace the superadmin `test-user` is also a member of, so a
-- share can be requested through the wrong workspace's path by a caller the route accepts.
INSERT INTO workspace (id, name, owner) VALUES
('test-workspace-2', 'test-workspace-2', 'test-user');
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
('test-workspace-2', 'cloud', 'test-key-2');
INSERT INTO workspace_settings (workspace_id) VALUES
('test-workspace-2');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('test-workspace-2', 'all', 'All users', '{}');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
+158
View File
@@ -13191,6 +13191,134 @@ paths:
type: boolean
description: more buckets matched than were returned, so summing them under-reports
/w/{workspace}/ai/shared_artifacts/share:
post:
summary: share an AI session artifact with the workspace
description: >
Stores a read-only copy that any member of the workspace can open by id. Sharing the
same artifact again updates that copy, keeps its id, and restarts its retention window.
operationId: shareAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- artifact_id
- name
- kind
- version
- content
properties:
artifact_id:
type: string
description: the artifact's id in the author's session
name:
type: string
kind:
type: string
enum: [md, html]
version:
type: integer
minimum: 1
content:
type: string
responses:
"200":
description: the shared copy
content:
application/json:
schema:
$ref: "#/components/schemas/SharedAiArtifactInfo"
/w/{workspace}/ai/shared_artifacts/status:
get:
summary: get the calling user's share of one of their AI session artifacts
operationId: getAiArtifactShareStatus
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: artifact_id
in: query
required: true
schema:
type: string
responses:
"200":
description: the live share, if any, and how long shares last
content:
application/json:
schema:
type: object
required:
- retention_secs
properties:
retention_secs:
type: integer
share:
$ref: "#/components/schemas/SharedAiArtifactInfo"
/w/{workspace}/ai/shared_artifacts/get/{id}:
get:
summary: get a shared AI session artifact
operationId: getSharedAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the shared artifact
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/SharedAiArtifactInfo"
- type: object
required:
- content
- can_unshare
properties:
content:
type: string
can_unshare:
type: boolean
description: whether the caller authored the share or is a workspace admin
/w/{workspace}/ai/shared_artifacts/delete/{id}:
delete:
summary: stop sharing an AI session artifact
operationId: unshareAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: share deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -28256,6 +28384,36 @@ components:
- output_tokens
- requests
SharedAiArtifactInfo:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
kind:
type: string
enum: [md, html]
version:
type: integer
created_by:
type: string
shared_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
required:
- id
- name
- kind
- version
- created_by
- shared_at
- expires_at
InstanceAIProviderSummary:
type: object
properties:
+4
View File
@@ -518,6 +518,10 @@ pub fn workspaced_service() -> Router {
// could make the server allocate and parse an arbitrarily large one.
// Sized well above a full batch of the shape below.
.layer(DefaultBodyLimit::max(AI_USAGE_BODY_LIMIT)),
)
.nest(
"/shared_artifacts",
crate::ai_shared_artifacts::workspaced_service(),
);
#[cfg(feature = "bedrock")]
@@ -0,0 +1,322 @@
/*
* 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}"))
}
+1
View File
@@ -69,6 +69,7 @@ mod ai;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod ai_shared_artifacts;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
@@ -577,6 +577,7 @@ pub const ENV_SETTINGS: &[&str] = &[
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_JOB_LOGS",
"OTEL_TRACES_RETENTION_SECS",
"AI_SHARED_ARTIFACT_RETENTION_SECS",
"DISABLE_S3_STORE",
"PG_SCHEMA",
"PG_LISTENER_REFRESH_PERIOD_SECS",
+17
View File
@@ -151,6 +151,7 @@ pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans
pub const DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = 60 * 60 * 24 * 30;
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
/// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower
@@ -229,6 +230,13 @@ pub fn service_log_retention_secs() -> i64 {
SERVICE_LOG_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed)
}
/// How long a shared AI session artifact stays viewable, in seconds, counted from the last time
/// its author shared it. Read by both the API, which stops serving an expired share, and the
/// monitor, which deletes it — so both must agree, which is why they share this one reader.
pub fn ai_shared_artifact_retention_secs() -> i64 {
*AI_SHARED_ARTIFACT_RETENTION_SECS
}
/// Canonical form of a base URL, used as one of the inputs to the offline-license
/// instance hash (`compute_instance_hash`).
///
@@ -476,6 +484,15 @@ lazy_static::lazy_static! {
/// [`set_otel_traces_retention_secs`] is the only writer, [`otel_traces_retention_secs`] the
/// only reader.
static ref OTEL_TRACES_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_OTEL_TRACES_RETENTION_SECS);
/// Read it with [`ai_shared_artifact_retention_secs`].
static ref AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = clamp_retention_secs(
std::env::var("AI_SHARED_ARTIFACT_RETENTION_SECS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS),
DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS,
"AI shared artifact",
);
pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false);
@@ -13,6 +13,7 @@
type WindmillItemKind,
type WorkspaceItemTargetKind
} from './workspaceItems.svelte'
import { safeHref } from './safeHref'
type Props = {
href?: string
@@ -45,6 +46,8 @@
const previewAction = $derived(available?.type === 'open_item_preview' ? available : undefined)
const drawerAction = $derived(available?.type === 'open_created_resource' ? available : undefined)
const allowedHref = $derived(safeHref(href, window.location.href))
const modifier = newTabModifier()
const hint = $derived(
@@ -68,7 +71,7 @@
}
</script>
{#if href}
{#if allowedHref}
{#if wmKind}
<!-- Only a preview pill can change icon, so only it is worth tracking the modifier for. -->
<span
@@ -76,7 +79,7 @@
{@attach previewAction ? modifier.attach : undefined}
>
<a
{href}
href={allowedHref}
target={previewAction ? undefined : '_blank'}
rel={previewAction ? undefined : 'noopener noreferrer'}
title={title || hint}
@@ -119,8 +122,11 @@
{/if}
</span>
{:else}
<a {href} target="_blank" rel="noopener noreferrer" {title}>
<a href={allowedHref} target="_blank" rel="noopener noreferrer" {title}>
{@render children?.()}
</a>
{/if}
{:else}
<!-- An empty or unsafe href still has text; drop only the link. -->
{@render children?.()}
{/if}
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Component } from 'svelte'
import Markdown from 'svelte-exmarkdown'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import { markdownPlugins } from '$lib/components/markdownPlugins'
import LinkRenderer from '../LinkRenderer.svelte'
interface Props {
content: string
/** The raw text rather than the rendered document. */
source: boolean
/**
* The fenced-code renderer. Defaults to the shared chain's, which keeps mermaid off:
* a shared artifact is another member's text. The session viewer passes the chat's,
* which draws diagrams and needs the chat context the shared page does not have.
*/
pre?: Component<any>
}
let { content, source, pre }: Props = $props()
// The shared chain (raw HTML re-parsed, then sanitized) rather than a chat-only one, because
// this body also renders what another member wrote. Renderers merge in order, so the chat's
// link pill and, when given, its code block sit on top of it.
const plugins = $derived([
...markdownPlugins,
{ renderer: { a: LinkRenderer, ...(pre ? { pre } : {}) } }
])
</script>
<!-- Rendered inside the caller's scroll container: the fade below is sticky to it. -->
{#if source}
<!-- key: SimpleEditor reads `code` only on init. -->
{#key content}
<SimpleEditor lang="markdown" code={content} readOnly class="h-full" />
{/key}
{:else}
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
The negative margin cancels its flow height so it overlays instead of pushing. -->
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
<div class="pb-4 pt-2 {markdownProse.doc}">
<Markdown md={content} {plugins} />
</div>
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
import { Check, Copy, Download } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { copyToClipboard, download } from '$lib/utils'
import { artifactFilename, artifactMimeType, type ArtifactKind } from './artifactsDB'
interface Props {
name: string
kind: ArtifactKind
content: string
disabled?: boolean
}
let { name, kind, content, disabled = false }: Props = $props()
let copied = $state(false)
async function copyRaw() {
if (!(await copyToClipboard(content))) return
copied = true
setTimeout(() => (copied = false), 1500)
}
</script>
<!-- A Button's `disabled` does not reach its dropdown items, so the item carries its own. -->
<Button
unifiedSize="sm"
variant="default"
{disabled}
startIcon={{ icon: copied ? Check : Copy }}
onClick={copyRaw}
title="Copy raw markdown"
dropdownItems={[
{
label: 'Download as .md',
icon: Download,
onClick: () => download(artifactFilename({ name, kind }), content, artifactMimeType(kind)),
disabled
}
]}
>
{copied ? 'Copied' : 'Copy'}
</Button>
@@ -0,0 +1,196 @@
<script lang="ts">
import { Link, Share2 } from 'lucide-svelte'
import { resource } from 'runed'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import { AiService, type SharedAiArtifactInfo } from '$lib/gen'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, displayDate } from '$lib/utils'
import type { ArtifactKind } from './artifactsDB'
import { formatRetention, sharedArtifactUrl, shareWorkspaceId } from './artifactSharing'
interface Props {
artifactId: string
name: string
kind: ArtifactKind
content: string
/** The version on screen, which is the one a share copies. */
version: number
disabled?: boolean
}
let { artifactId, name, kind, content, version, disabled = false }: Props = $props()
const workspace = $derived(
$workspaceStore ? shareWorkspaceId($workspaceStore, $userWorkspaces) : undefined
)
const status = resource(
() => ({ workspace, artifactId }),
async ({ workspace, artifactId }) => {
if (!workspace) return undefined
try {
return await AiService.getAiArtifactShareStatus({ workspace, artifactId })
} catch (err) {
// The button still works without it: sharing reports its own failure.
console.error('Could not read the artifact share status', err)
return undefined
}
}
)
const share = $derived(status.current?.share)
const url = $derived(share && workspace ? sharedArtifactUrl(workspace, share.id) : undefined)
// Which way re-sharing would move the link: a newer version on screen is an update, an
// older one (a pinned approved plan, say) is a deliberate switch back, and the same version
// under another name is a rename, which earns no version of its own.
const change = $derived.by(() => {
if (!share) return undefined
if (share.version < version) return 'newer'
if (share.version > version) return 'older'
return share.name !== name.trim() ? 'renamed' : undefined
})
let saving = $state(false)
function errorMessage(err: unknown): string {
const body = (err as { body?: unknown })?.body
return typeof body === 'string' && body ? body : String(err)
}
function reflect(next: SharedAiArtifactInfo | undefined) {
if (!status.current) return
status.mutate({ ...status.current, share: next })
}
async function shareVersion() {
if (!workspace) return
const updating = share !== undefined
saving = true
try {
const shared = await AiService.shareAiArtifact({
workspace,
requestBody: { artifact_id: artifactId, name, kind, version, content }
})
if (status.current) reflect(shared)
else await status.refetch()
if (updating) {
sendUserToast(`The link now shows v${shared.version}`)
} else if (await copyToClipboard(sharedArtifactUrl(workspace, shared.id), false)) {
sendUserToast('Link copied to clipboard')
}
} catch (err) {
sendUserToast(`Could not share the artifact: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
async function stopSharing() {
if (!workspace || !share) return
saving = true
try {
await AiService.unshareAiArtifact({ workspace, id: share.id })
reflect(undefined)
sendUserToast('Stopped sharing: the link no longer opens')
} catch (err) {
sendUserToast(`Could not stop sharing: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
</script>
<Popover
placement="bottom-end"
contentClasses="!bg-surface"
{disabled}
triggerAttrs={{ 'aria-label': 'Share artifact', 'aria-haspopup': 'dialog' }}
>
{#snippet trigger()}
<Button
unifiedSize="sm"
variant="default"
nonCaptureEvent
disabled={disabled || !workspace}
startIcon={{ icon: share ? Link : Share2 }}
title={share ? 'Shared with the workspace' : 'Share with the workspace'}
>
{share ? 'Shared' : 'Share'}
</Button>
{/snippet}
{#snippet content()}
<div class="flex w-80 flex-col gap-3 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="font-semibold text-emphasis">Share with workspace</span>
<span class="font-normal text-secondary">
{#if share}
Members of {workspace} can open a read-only copy of v{share.version} with this link.
{:else}
Members of {workspace} will be able to open a read-only copy of v{version} with a link.
{/if}
{#if status.current}
The copy is deleted {formatRetention(status.current.retention_secs)} after it is shared.
{/if}
</span>
</div>
{#if share && url}
<div class="flex flex-col gap-1">
<ClipboardPanel content={url} size="sm" />
<span class="text-2xs font-normal text-hint">
Expires {displayDate(share.expires_at)}
</span>
</div>
{#if change}
<div class="flex items-center justify-between gap-2">
<span class="font-normal text-secondary">
{#if change === 'newer'}
The link shows v{share.version}; v{version} is on screen.
{:else if change === 'older'}
The link shows v{share.version}, newer than the v{version} on screen.
{:else}
The link still shows the old name, “{share.name}”.
{/if}
</span>
<Button unifiedSize="sm" variant="accent" loading={saving} onClick={shareVersion}>
{#if change === 'newer'}
Update link to v{version}
{:else if change === 'older'}
Share v{version} instead
{:else}
Update name
{/if}
</Button>
</div>
{/if}
<div class="flex justify-end">
<Button
unifiedSize="sm"
variant="default"
destructive
disabled={saving}
onClick={stopSharing}
>
Stop sharing
</Button>
</div>
{:else}
<div class="flex justify-end">
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Link }}
loading={saving}
disabled={status.loading}
onClick={shareVersion}
>
Create link
</Button>
</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -1,23 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Code, Eye, FileText, Copy, Check, Download, ClipboardList } from 'lucide-svelte'
import { Code, Eye, FileText, ClipboardList } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { copyToClipboard, download } from '$lib/utils'
import { currentVersion, type ArtifactVersion, type PersistedArtifact } from './artifactsDB'
import CodeDisplay from '../script/CodeDisplay.svelte'
import LinkRenderer from '../LinkRenderer.svelte'
import {
artifactFilename,
artifactMimeType,
currentVersion,
type ArtifactVersion,
type PersistedArtifact
} from './artifactsDB'
import { markdownProse } from '$lib/components/markdownProse'
import ArtifactBody from './ArtifactBody.svelte'
import ArtifactExportButton from './ArtifactExportButton.svelte'
import ArtifactShareButton from './ArtifactShareButton.svelte'
import ArtifactVersionPicker from './ArtifactVersionPicker.svelte'
import type { SessionArtifactsStore } from './artifactsState.svelte'
import { History } from 'lucide-svelte'
@@ -114,22 +105,6 @@
const backTo = $derived(view.backToPlan)
let showSource = $state(false)
const source = $derived(!canPreview || showSource)
let copied = $state(false)
async function copyRaw() {
if (!(await copyToClipboard(shown.content))) return
copied = true
setTimeout(() => (copied = false), 1500)
}
function downloadFile() {
download(
artifactFilename({ name: shown.name, kind: artifact.kind }),
shown.content,
artifactMimeType(artifact.kind)
)
}
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
</script>
<div class="flex flex-col h-full bg-surface-tertiary">
@@ -169,27 +144,22 @@
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Copy raw markdown, with a dropdown for the download-as-file variant. Both export
`shown`, which is still the current document while a pin is restoring — so both
are disabled, the item explicitly: a Button's `disabled` does not reach it. -->
<Button
unifiedSize="sm"
variant="default"
<!-- Both export and share `shown`, which is still the current document while a pin is
restoring, so both wait for it. -->
<ArtifactShareButton
artifactId={artifact.id}
name={shown.name}
kind={artifact.kind}
content={shown.content}
version={shownVersion ?? latest}
disabled={restoringPin}
startIcon={{ icon: copied ? Check : Copy }}
onClick={copyRaw}
title="Copy raw markdown"
dropdownItems={[
{
label: 'Download as .md',
icon: Download,
onClick: downloadFile,
disabled: restoringPin
}
]}
>
{copied ? 'Copied' : 'Copy'}
</Button>
/>
<ArtifactExportButton
name={shown.name}
kind={artifact.kind}
content={shown.content}
disabled={restoringPin}
/>
{#if canPreview}
<ToggleButtonGroup
noWFull
@@ -269,19 +239,8 @@
<div class="flex-1 min-h-0 overflow-auto px-8">
{#if restoringPin}
<!-- Deliberately blank until the pinned snapshot lands; see restoringPin. -->
{:else if source}
<!-- key: SimpleEditor reads `code` only on init; remount on id or content change. -->
{#key `${artifact.id}:${pinnedContent ? `v${pinnedContent.version}` : artifact.updatedAt}`}
<SimpleEditor lang="markdown" code={shown.content} readOnly class="h-full" />
{/key}
{:else}
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
The negative margin cancels its flow height so it overlays instead of pushing. -->
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
<div class="pb-4 pt-2 {markdownProse.doc}">
<Markdown md={shown.content} {plugins} />
</div>
<ArtifactBody content={shown.content} {source} pre={CodeDisplay} />
{/if}
</div>
</div>
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('$lib/base', () => ({ base: '' }))
import { shareWorkspaceId } from './artifactSharing'
describe('shareWorkspaceId', () => {
it('shares a fork session into the topmost workspace the user still belongs to', () => {
const workspaces = [
{ id: 'prod' },
{ id: 'wm-fork-a', parent_workspace_id: 'prod' },
{ id: 'wm-fork-b', parent_workspace_id: 'wm-fork-a' }
]
expect(shareWorkspaceId('wm-fork-b', workspaces)).toBe('prod')
})
it('stops below a parent the user is not a member of', () => {
const workspaces = [{ id: 'wm-fork-a', parent_workspace_id: 'prod' }]
expect(shareWorkspaceId('wm-fork-a', workspaces)).toBe('wm-fork-a')
})
})
@@ -0,0 +1,42 @@
import { base } from '$lib/base'
/**
* The workspace a share from `workspaceId` lands in: the topmost ancestor the user still
* belongs to. A session often runs in a fork, whose members are its creator alone, so a link
* minted there would reach nobody — and it would be deleted with the fork.
*/
export function shareWorkspaceId(
workspaceId: string,
workspaces: { id: string; parent_workspace_id?: string | null }[]
): string {
let current = workspaceId
const seen = new Set([current])
for (;;) {
const parent = workspaces.find((w) => w.id === current)?.parent_workspace_id
if (!parent || seen.has(parent) || !workspaces.some((w) => w.id === parent)) return current
seen.add(parent)
current = parent
}
}
export function sharedArtifactUrl(workspaceId: string, shareId: string): string {
return `${window.location.origin}${base}/shared_artifacts/${encodeURIComponent(
shareId
)}?workspace=${encodeURIComponent(workspaceId)}`
}
/** "30 days", "12 hours": the retention window in the largest whole unit it fills. */
export function formatRetention(secs: number): string {
const units: [string, number][] = [
['day', 86400],
['hour', 3600],
['minute', 60]
]
for (const [unit, size] of units) {
if (secs >= size) {
const n = Math.floor(secs / size)
return `${n} ${unit}${n === 1 ? '' : 's'}`
}
}
return `${secs} second${secs === 1 ? '' : 's'}`
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { safeHref } from './safeHref'
const BASE = 'https://app.example.com/sessions?workspace=demo'
describe('safeHref', () => {
it.each([
'https://windmill.dev/docs',
'http://localhost:3000/',
'mailto:someone@example.com',
'/runs/abc',
'#anchor',
'docs/page'
])('keeps %s', (href) => {
expect(safeHref(href, BASE)).toBe(href)
})
it.each([
'javascript:alert(1)',
'JavaScript:alert(1)',
' javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox',
'file:///etc/passwd'
])('drops %s', (href) => {
expect(safeHref(href, BASE)).toBeUndefined()
})
it('drops a missing or empty href', () => {
expect(safeHref(undefined, BASE)).toBeUndefined()
expect(safeHref('', BASE)).toBeUndefined()
})
})
@@ -0,0 +1,17 @@
const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:']
/**
* The href a rendered markdown link may carry, or undefined for one that must be dropped.
*
* Markdown reaches the chat renderers from a model, and from another member for a shared
* artifact, and `svelte-exmarkdown` passes `javascript:` and `data:` hrefs through untouched.
* Relative links resolve against `base` (the page), so they stay.
*/
export function safeHref(href: string | undefined, base: string): string | undefined {
if (!href) return undefined
try {
return SAFE_PROTOCOLS.includes(new URL(href, base).protocol) ? href : undefined
} catch {
return undefined
}
}
@@ -0,0 +1,144 @@
<script lang="ts">
import { page } from '$app/state'
import { resource } from 'runed'
import { Code, Eye, FileText, Link2Off } from 'lucide-svelte'
import { Button, EmptyState, Skeleton } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ArtifactBody from '$lib/components/copilot/chat/artifacts/ArtifactBody.svelte'
import ArtifactExportButton from '$lib/components/copilot/chat/artifacts/ArtifactExportButton.svelte'
import { AiService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
const id = $derived(page.params.id ?? '')
// The link carries its workspace; the store only catches up once the layout applies it.
const workspace = $derived(page.url.searchParams.get('workspace') ?? $workspaceStore)
type Loaded =
| { state: 'found'; artifact: Awaited<ReturnType<typeof AiService.getSharedAiArtifact>> }
| { state: 'gone' }
| { state: 'error'; message: string }
const shared = resource(
() => ({ workspace, id }),
async ({ workspace, id }): Promise<Loaded | undefined> => {
if (!workspace || !id) return undefined
try {
return {
state: 'found',
artifact: await AiService.getSharedAiArtifact({ workspace, id })
}
} catch (err) {
const status = (err as { status?: number })?.status
// A malformed id is a 400 from the path extractor, and means the same to the reader.
if (status === 404 || status === 400) return { state: 'gone' }
return { state: 'error', message: String((err as { body?: unknown })?.body ?? err) }
}
}
)
const artifact = $derived(shared.current?.state === 'found' ? shared.current.artifact : undefined)
let showSource = $state(false)
const source = $derived(artifact?.kind !== 'md' || showSource)
let removing = $state(false)
async function stopSharing() {
if (!workspace || !artifact) return
removing = true
try {
await AiService.unshareAiArtifact({ workspace, id: artifact.id })
shared.mutate({ state: 'gone' })
sendUserToast('Stopped sharing: the link no longer opens')
} catch (err) {
sendUserToast(`Could not stop sharing: ${(err as { body?: unknown })?.body ?? err}`, true)
} finally {
removing = false
}
}
</script>
<div class="flex h-full flex-col px-4 py-6 sm:px-8">
<div class="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col gap-3">
{#if artifact}
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<FileText size={16} class="shrink-0 text-secondary" />
<h1 class="truncate text-lg font-semibold text-emphasis" title={artifact.name}>
{artifact.name}
</h1>
</div>
<span class="text-xs font-normal text-secondary">
Shared by {artifact.created_by} · v{artifact.version} · {displayDate(
artifact.shared_at
)} · expires {displayDate(artifact.expires_at)}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
{#if artifact.can_unshare}
<Button
unifiedSize="sm"
variant="default"
destructive
loading={removing}
onClick={stopSharing}
>
Stop sharing
</Button>
{/if}
<ArtifactExportButton
name={artifact.name}
kind={artifact.kind}
content={artifact.content}
/>
{#if artifact.kind === 'md'}
<ToggleButtonGroup
noWFull
selected={showSource ? 'source' : 'preview'}
onSelected={(v) => (showSource = v === 'source')}
>
{#snippet children({ item })}
<ToggleButton
{item}
value="preview"
icon={Eye}
iconOnly
tooltip="Preview"
size="sm"
/>
<ToggleButton
{item}
value="source"
icon={Code}
iconOnly
tooltip="View source"
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-surface-tertiary px-8">
<ArtifactBody content={artifact.content} {source} />
</div>
{:else if shared.current?.state === 'gone'}
<EmptyState
icon={Link2Off}
title="This shared artifact is no longer available"
description="Its link expired, or its author stopped sharing it."
/>
{:else if shared.current?.state === 'error'}
<EmptyState
icon={Link2Off}
title="Could not load this shared artifact"
description={shared.current.message}
/>
{:else}
<Skeleton layout={[[3], 1, [30]]} />
{/if}
</div>
</div>
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Shared artifact' }
}
}