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}