Skip to main content

pipedash_web/
demo.rs

1use axum::{
2    extract::Request,
3    http::{
4        Method,
5        StatusCode,
6    },
7    middleware::Next,
8    response::{
9        IntoResponse,
10        Response,
11    },
12};
13
14use crate::error::{
15    ApiError,
16    AppError,
17};
18use crate::state::CurrentDevice;
19
20pub fn enabled() -> bool {
21    std::env::var("PIPEDASH_DEMO_MODE").is_ok_and(|value| crate::env_flag(&value))
22}
23
24pub fn is_blocked(method: &Method, path: &str) -> bool {
25    let Some(route) = path.strip_prefix("/api/v1/") else {
26        return false;
27    };
28
29    if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
30        return route.starts_with("storage/")
31            || route == "providers/export"
32            || (route.starts_with("push/") && route != "push/relay");
33    }
34
35    !(*method == Method::POST && allows_post(route))
36}
37
38fn allows_post(route: &str) -> bool {
39    route == "vault/unlock"
40        || route == "refresh/all"
41        || route == "metrics/aggregated"
42        || route == "devices/pairing"
43        || (route.starts_with("providers/") && route.ends_with("/refresh"))
44        || (route.starts_with("metrics/pipelines/") && route.ends_with("/query"))
45        || (route.starts_with("pipelines/")
46            && (route.ends_with("/search") || route.ends_with("/download-ticket")))
47}
48
49pub fn device_name(platform: &str) -> String {
50    let label = match platform {
51        "macos" => "Mac",
52        "windows" => "Windows PC",
53        "ios" => "iPhone or iPad",
54        "android" => "Android device",
55        _ => "Linux PC",
56    };
57    format!("Demo {label}")
58}
59
60fn device_owned_write(req: &Request) -> bool {
61    let Some(device) = req.extensions().get::<CurrentDevice>() else {
62        return false;
63    };
64    let path = req.uri().path();
65
66    (*req.method() == Method::DELETE
67        && path
68            .strip_prefix("/api/v1/devices/")
69            .is_some_and(|id| id == device.0))
70        || path.starts_with("/api/v1/devices/current/push")
71        || path == "/api/v1/devices/current/notification-preferences"
72}
73
74pub async fn reject_changes(req: Request, next: Next) -> Response {
75    if is_blocked(req.method(), req.uri().path()) && !device_owned_write(&req) {
76        return AppError::new(
77            StatusCode::FORBIDDEN,
78            ApiError::new(
79                "DEMO_READ_ONLY",
80                "This is a read-only demo. Install Pipedash to connect your own providers.",
81            ),
82        )
83        .into_response();
84    }
85
86    next.run(req).await
87}
88
89#[cfg(test)]
90mod tests {
91    use axum::{
92        body::Body,
93        routing::post,
94        Router,
95    };
96    use tower::ServiceExt;
97
98    use super::*;
99
100    #[test]
101    fn blocks_configuration_and_pipeline_actions() {
102        let pipeline = "/api/v1/pipelines/github__1__acme__web__ci";
103        let blocked = [
104            (Method::POST, "/api/v1/factory-reset".to_string()),
105            (Method::POST, "/api/v1/providers".to_string()),
106            (Method::PUT, "/api/v1/providers/1".to_string()),
107            (Method::DELETE, "/api/v1/providers/1".to_string()),
108            (
109                Method::PUT,
110                "/api/v1/providers/1/refresh-interval".to_string(),
111            ),
112            (Method::POST, format!("{pipeline}/trigger")),
113            (Method::POST, format!("{pipeline}/runs/7/cancel")),
114            (Method::POST, format!("{pipeline}/runs/7/rerun")),
115            (Method::PUT, "/api/v1/settings/general".to_string()),
116            (
117                Method::PUT,
118                "/api/v1/preferences/table/1/pipeline_runs".to_string(),
119            ),
120            (Method::POST, "/api/v1/vault/lock".to_string()),
121            (Method::POST, "/api/v1/setup/config".to_string()),
122            (Method::DELETE, "/api/v1/cache".to_string()),
123            (Method::GET, "/api/v1/storage/config/content".to_string()),
124            (Method::GET, "/api/v1/providers/export".to_string()),
125            (Method::GET, "/api/v1/push/subscriptions".to_string()),
126            (Method::DELETE, "/api/v1/devices/abc".to_string()),
127            (Method::PUT, "/api/v1/push/relay".to_string()),
128        ];
129
130        for (method, path) in blocked {
131            assert!(
132                is_blocked(&method, &path),
133                "{method} {path} should be blocked"
134            );
135        }
136    }
137
138    #[test]
139    fn allows_reads_and_queries() {
140        let allowed = [
141            (Method::GET, "/api/v1/pipelines/cached"),
142            (Method::GET, "/api/v1/providers/1"),
143            (Method::GET, "/api/v1/ws"),
144            (Method::GET, "/"),
145            (Method::GET, "/assets/index.js"),
146            (Method::OPTIONS, "/api/v1/providers"),
147            (Method::POST, "/api/v1/pipelines/p/runs/7/logs/job/search"),
148            (
149                Method::POST,
150                "/api/v1/pipelines/p/runs/7/logs/job/download-ticket",
151            ),
152            (Method::POST, "/api/v1/metrics/aggregated"),
153            (Method::POST, "/api/v1/metrics/pipelines/p/query"),
154            (Method::POST, "/api/v1/providers/1/refresh"),
155            (Method::POST, "/api/v1/refresh/all"),
156            (Method::POST, "/api/v1/vault/unlock"),
157            (Method::POST, "/api/v1/devices/pairing"),
158            (Method::GET, "/api/v1/push/relay"),
159        ];
160
161        for (method, path) in allowed {
162            assert!(
163                !is_blocked(&method, path),
164                "{method} {path} should be allowed"
165            );
166        }
167    }
168
169    #[test]
170    fn demo_devices_get_a_name_without_the_owner_s_hostname() {
171        assert_eq!(device_name("ios"), "Demo iPhone or iPad");
172        assert_eq!(device_name("macos"), "Demo Mac");
173        assert_eq!(device_name("linux"), "Demo Linux PC");
174    }
175
176    #[tokio::test]
177    async fn rejected_requests_return_the_demo_error() {
178        let app = Router::new()
179            .route("/api/v1/factory-reset", post(|| async { "ok" }))
180            .route("/api/v1/refresh/all", post(|| async { "ok" }))
181            .layer(axum::middleware::from_fn(reject_changes));
182        let send = |uri: &str| {
183            app.clone()
184                .oneshot(axum::http::Request::post(uri).body(Body::empty()).unwrap())
185        };
186
187        let rejected = send("/api/v1/factory-reset").await.unwrap();
188        assert_eq!(rejected.status(), StatusCode::FORBIDDEN);
189        let body = axum::body::to_bytes(rejected.into_body(), usize::MAX)
190            .await
191            .unwrap();
192        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
193        assert_eq!(body["code"], "DEMO_READ_ONLY");
194
195        let allowed = send("/api/v1/refresh/all").await.unwrap();
196        assert_eq!(allowed.status(), StatusCode::OK);
197    }
198
199    #[tokio::test]
200    async fn a_paired_device_can_remove_only_itself() {
201        let app = Router::new()
202            .route(
203                "/api/v1/devices/{id}",
204                axum::routing::delete(|| async { "ok" }),
205            )
206            .layer(axum::middleware::from_fn(reject_changes));
207        let remove = |path: &str, device: Option<&str>| {
208            let mut request = axum::http::Request::delete(path)
209                .body(Body::empty())
210                .unwrap();
211            if let Some(id) = device {
212                request
213                    .extensions_mut()
214                    .insert(CurrentDevice(id.to_string()));
215            }
216            app.clone().oneshot(request)
217        };
218
219        let own = remove("/api/v1/devices/abc", Some("abc")).await.unwrap();
220        assert_eq!(own.status(), StatusCode::OK);
221        for (path, device) in [
222            ("/api/v1/devices/abc", Some("other")),
223            ("/api/v1/devices/abc", None),
224        ] {
225            let response = remove(path, device).await.unwrap();
226            assert_eq!(response.status(), StatusCode::FORBIDDEN, "{device:?}");
227        }
228    }
229
230    #[tokio::test]
231    async fn a_paired_device_manages_only_its_own_push_registration_and_preferences() {
232        let app = Router::new()
233            .route(
234                "/api/v1/devices/current/push",
235                axum::routing::put(|| async { "ok" }).delete(|| async { "ok" }),
236            )
237            .route("/api/v1/devices/current/push/test", post(|| async { "ok" }))
238            .route("/api/v1/devices/current/push/ack", post(|| async { "ok" }))
239            .route(
240                "/api/v1/devices/current/notification-preferences",
241                axum::routing::put(|| async { "ok" }),
242            )
243            .route(
244                "/api/v1/devices/other",
245                axum::routing::put(|| async { "ok" }),
246            )
247            .layer(axum::middleware::from_fn(reject_changes));
248        let send = |method: Method, path: &str, device: Option<&str>| {
249            let mut request = axum::http::Request::builder()
250                .method(method)
251                .uri(path)
252                .body(Body::empty())
253                .unwrap();
254            if let Some(id) = device {
255                request
256                    .extensions_mut()
257                    .insert(CurrentDevice(id.to_string()));
258            }
259            app.clone().oneshot(request)
260        };
261
262        for (method, path) in [
263            (Method::PUT, "/api/v1/devices/current/push"),
264            (Method::DELETE, "/api/v1/devices/current/push"),
265            (Method::POST, "/api/v1/devices/current/push/test"),
266            (Method::POST, "/api/v1/devices/current/push/ack"),
267            (
268                Method::PUT,
269                "/api/v1/devices/current/notification-preferences",
270            ),
271        ] {
272            let own = send(method.clone(), path, Some("abc")).await.unwrap();
273            assert_eq!(own.status(), StatusCode::OK, "{method} {path}");
274            let anonymous = send(method.clone(), path, None).await.unwrap();
275            assert_eq!(anonymous.status(), StatusCode::FORBIDDEN, "{method} {path}");
276        }
277        let other = send(Method::PUT, "/api/v1/devices/other", Some("abc"))
278            .await
279            .unwrap();
280        assert_eq!(other.status(), StatusCode::FORBIDDEN);
281    }
282}