Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ jobs:
run: |
docker run --rm \
-v "$PWD/apps/web:/web" \
-v "$PWD/wiki:/wiki:ro" \
-w /web \
-e PUBLIC_SERVER_URL=/api/v1 \
-e DEVFORGE_WIKI_DIR=/wiki \
node:22-bookworm \
bash -lc 'npm install --include=optional && npm run build && npm test'

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ WORKDIR /web
COPY apps/web/package.json apps/web/package-lock.json ./
RUN npm install --include=optional
COPY apps/web/ ./
ENV PUBLIC_SERVER_URL=/api/v1
# Wiki public (/wiki) : les pages wiki/*.md sont figées dans le build du front.
COPY wiki/ /wiki/
ENV PUBLIC_SERVER_URL=/api/v1 \
DEVFORGE_WIKI_DIR=/wiki
RUN npm run build

FROM rust:bookworm AS builder
Expand Down
1 change: 1 addition & 0 deletions apps/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ subtle = "2.6"
png = "0.18"

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
wiremock = "0.6"

[target.'cfg(target_os = "linux")'.dependencies]
Expand Down
12 changes: 2 additions & 10 deletions apps/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ mod runner_routes;
mod runner_store;
mod secret_box;
mod security;
mod static_web;
mod spec_routes;
mod sso;
mod sso_routes;
Expand All @@ -92,7 +93,6 @@ use serde_json::{json, Value};
use state::AppState;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

Expand Down Expand Up @@ -475,17 +475,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_state(state);

// Front Astro : `web/` à côté du programme, share/devforge (Flatpak), ou DEVFORGE_STATIC_DIR.
let mut serving_web = false;
if let Some(root) = paths::web_dir() {
let index = root.join("index.html");
tracing::info!(path = %root.display(), "serving static web assets");
serving_web = true;
if index.is_file() {
app =
app.fallback_service(ServeDir::new(&root).not_found_service(ServeFile::new(index)));
} else {
app = app.fallback_service(ServeDir::new(&root));
}
app = static_web::with_static_dir(app, &root);
}
serve_http(app, "server").await?;
Ok(())
Expand Down
172 changes: 172 additions & 0 deletions apps/server/src/static_web.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Front Astro statique (`web/`) : fichiers, repli SPA et wiki public.
//!
//! Le wiki est figé au build du front (`web/wiki/<Slug>/index.html`, lu depuis
//! `wiki/*.md`) : servi comme n'importe quel fichier statique, sans session ni
//! base. `ServeDir` refuse `..` et les chemins hors racine. Une page de wiki
//! inconnue renvoie l'accueil du wiki (qui l'annonce) plutôt que la vitrine.

use std::path::{Path, PathBuf};

use axum::{
body::Body,
http::{header, HeaderValue, Request, StatusCode},
response::{IntoResponse, Response},
routing::any,
Router,
};
use tower_http::services::ServeDir;

/// Chemin sous `/wiki` ? (`/wiki`, `/wiki/`, `/wiki/x/…`)
pub fn is_wiki_path(path: &str) -> bool {
path == "/wiki" || path.starts_with("/wiki/")
}

/// Fichier HTML servi quand aucun fichier ne correspond à `path`.
pub fn not_found_page(root: &Path, path: &str) -> Option<PathBuf> {
if is_wiki_path(path) {
let wiki = root.join("wiki").join("index.html");
if wiki.is_file() {
return Some(wiki);
}
}
let index = root.join("index.html");
index.is_file().then_some(index)
}

async fn serve_not_found(root: PathBuf, path: String) -> Response {
let Some(file) = not_found_page(&root, &path) else {
return StatusCode::NOT_FOUND.into_response();
};
match tokio::fs::read(&file).await {
Ok(bytes) => {
let mut res = (StatusCode::NOT_FOUND, Body::from(bytes)).into_response();
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
res
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}

/// Ajoute le service statique en repli du routeur (API d'abord, puis fichiers).
pub fn with_static_dir<S>(app: Router<S>, root: &Path) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let base = root.to_path_buf();
let fallback = any(move |req: Request<Body>| {
let base = base.clone();
let path = req.uri().path().to_string();
async move { serve_not_found(base, path).await }
});
app.fallback_service(ServeDir::new(root).fallback(fallback))
}

#[cfg(test)]
mod tests {
use super::*;
use axum::routing::get;
use tower::ServiceExt;

fn site() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"df-static-web-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("wiki/Kubernetes")).unwrap();
std::fs::write(dir.join("index.html"), "VITRINE").unwrap();
std::fs::write(dir.join("wiki/index.html"), "WIKI-HOME").unwrap();
std::fs::write(dir.join("wiki/Kubernetes/index.html"), "WIKI-K8S").unwrap();
std::fs::write(dir.join("wiki/search-index.json"), "[]").unwrap();
// Hors racine : ne doit jamais être servi.
std::fs::write(dir.with_extension("secret"), "SECRET").unwrap();
dir
}

async fn get_body(app: Router, uri: &str) -> (StatusCode, String) {
let res = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}

fn app(root: &Path) -> Router {
with_static_dir(
Router::new().route("/api/v1/health", get(|| async { "ok" })),
root,
)
}

#[tokio::test]
async fn wiki_pages_are_public_static_files() {
let root = site();
let (s, b) = get_body(app(&root), "/wiki/Kubernetes/").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b, "WIKI-K8S");
let (s, b) = get_body(app(&root), "/wiki/").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b, "WIKI-HOME");
let (s, _) = get_body(app(&root), "/wiki/search-index.json").await;
assert_eq!(s, StatusCode::OK);
let (_, b) = get_body(app(&root), "/api/v1/health").await;
assert_eq!(b, "ok");
}

#[tokio::test]
async fn unknown_wiki_page_falls_back_to_wiki_home() {
let root = site();
let (s, b) = get_body(app(&root), "/wiki/Nope/").await;
assert_eq!(s, StatusCode::NOT_FOUND);
assert_eq!(b, "WIKI-HOME");
let (_, b) = get_body(app(&root), "/app/inconnu").await;
assert_eq!(b, "VITRINE");
}

#[tokio::test]
async fn traversal_never_leaves_web_root() {
let root = site();
let name = root.with_extension("secret");
let name = name.file_name().unwrap().to_string_lossy();
for uri in [
format!("/wiki/../../{name}"),
format!("/wiki/%2e%2e/%2e%2e/{name}"),
format!("/wiki/..%2f..%2f{name}"),
format!("/..%5c{name}"),
] {
let (_, b) = get_body(app(&root), &uri).await;
assert!(
!b.contains("SECRET"),
"{uri} a servi un fichier hors racine"
);
}
}

#[test]
fn wiki_path_detection() {
assert!(is_wiki_path("/wiki"));
assert!(is_wiki_path("/wiki/Kubernetes/"));
assert!(!is_wiki_path("/wikipedia"));
assert!(!is_wiki_path("/app/wiki"));
}

#[test]
fn missing_wiki_build_falls_back_to_site_index() {
let root = site();
std::fs::remove_file(root.join("wiki/index.html")).unwrap();
assert_eq!(
not_found_page(&root, "/wiki/x"),
Some(root.join("index.html"))
);
}
}
15 changes: 4 additions & 11 deletions apps/server/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ use devforge_cluster::{
use devforge_deploy::{LocalShellExecutor, RemoteExecutor};
use serde::Deserialize;
use serde_json::{json, Value};
use tower_http::services::{ServeDir, ServeFile};

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
Expand Down Expand Up @@ -454,17 +453,11 @@ async fn worker_update_start(
})))
}

pub fn with_static_fallback(mut app: Router) -> Router {
if let Some(root) = crate::paths::web_dir() {
let index = root.join("index.html");
if index.is_file() {
app =
app.fallback_service(ServeDir::new(&root).not_found_service(ServeFile::new(index)));
} else {
app = app.fallback_service(ServeDir::new(&root));
}
pub fn with_static_fallback(app: Router) -> Router {
match crate::paths::web_dir() {
Some(root) => crate::static_web::with_static_dir(app, &root),
None => app,
}
app
}

pub fn is_worker_role(local: &LocalClusterState) -> bool {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/marketing/SiteFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export function SiteFooter() {
<a class="hover:text-[var(--color-ink)]" href="/app">
App
</a>
<a class="hover:text-[var(--color-ink)]" href="/wiki/">
Wiki
</a>
<a
class="hover:text-[var(--color-ink)]"
href="https://github.com/bobdivx/devforge"
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/marketing/SiteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const LINKS = [
{ href: '/#features', label: 'Produit' },
{ href: '/#metrics', label: 'Metrics' },
{ href: '/#workflow', label: 'Workflow' },
{ href: '/wiki/', label: 'Wiki' },
] as const;

export function SiteHeader() {
Expand Down
Loading
Loading