1pub mod files;
2pub mod lsp;
3
4use std::{future::IntoFuture, str::FromStr};
5
6use axum::{
7 Router,
8 error_handling::HandleErrorLayer,
9 extract,
10 response::{IntoResponse, Redirect},
11};
12use axum_login::AuthManagerLayerBuilder;
13use axum_macros::FromRef;
14use flams_database::DBBackend;
15use flams_git::gl::auth::GitLabOAuth;
16use flams_router_base::ws::WSServerSocket;
17use flams_system::settings::Settings;
18use http::{StatusCode, Uri};
19use leptos::prelude::*;
20use leptos_axum::{LeptosRoutes, generate_route_list};
21use leptos_meta::HashedStylesheet;
22use tower::{ServiceBuilder, ServiceExt};
23use tower_sessions::{Expiry, MemoryStore};
24use tracing::{Instrument, instrument};
25
26use flams_router_dashboard::{
27 Main,
28 ws::{self, WebSocketServer},
29};
30
31static SERVER_SPAN: std::sync::LazyLock<tracing::Span> =
32 std::sync::LazyLock::new(|| tracing::info_span!(target:"server",parent:None,"server"));
33
34#[inline]
35pub async fn run(port_channel: Option<tokio::sync::watch::Sender<Option<u16>>>) {
36 run_i(port_channel).instrument(SERVER_SPAN.clone()).await;
37}
38
39#[instrument(level = "info", target = "server", name = "run", skip_all)]
41async fn run_i(port_channel: Option<tokio::sync::watch::Sender<Option<u16>>>) {
42 let mut state = ServerState::new().in_current_span().await;
43 let mut addr = state.options.site_addr;
44 let mut changed = false;
45 let mut listener = None;
46 for p in addr.port()..65535 {
48 addr.set_port(p);
49 if let Ok(l) = tokio::net::TcpListener::bind(addr)
50 .await
52 {
53 listener = Some(l);
54 break;
55 }
56 changed = true;
57 }
58 let listener = listener.expect("Could not bind to any port");
59
60 leptos::prelude::Owner::new().set();
62
63 if changed {
64 if port_channel.is_some() {
65 tracing::warn!("Port already in use; used {} instead", addr.port());
66 } else {
67 println!("Port already in use; used {} instead", addr.port());
68 }
69 flams_system::settings::Settings::get()
70 .port
71 .store(addr.port(), std::sync::atomic::Ordering::Relaxed);
72 state.options.site_addr = addr;
73 }
74
75 ftml_components::set_backend::<flams_router_content::backend::FtmlBackend>();
76 ftml_components::set_continuation(&flams_router_content::Continuations);
77
78 let session_store = MemoryStore::default();
79 let session_layer = tower_sessions::SessionManagerLayer::new(session_store)
80 .with_expiry(Expiry::OnInactivity(
81 tower_sessions::cookie::time::Duration::days(5),
82 ))
83 .with_secure(false)
84 .with_same_site(tower_sessions::cookie::SameSite::Lax);
85
86 let auth_layer = ServiceBuilder::new()
87 .layer(HandleErrorLayer::new(|_| async {
88 http::StatusCode::BAD_REQUEST
89 }))
90 .layer(AuthManagerLayerBuilder::new(state.db.clone(), session_layer).build());
91
92 let routes = generate_route_list(Main);
93
94 let has_gl = state.oauth.is_some();
95
96 let mut app = axum::Router::<ServerState>::new()
97 .route("/ws/log", axum::routing::get(ws::LogSocket::ws_handler))
98 .route("/ws/queue", axum::routing::get(ws::QueueSocket::ws_handler))
99 .route("/ws/lsp", axum::routing::get(crate::server::lsp::register));
101
102 if has_gl {
103 app = app .route("/gitlab_login", axum::routing::get(gl_cont));
105 }
106
107 let app = app
108 .route(
109 "/api/{*fn_name}",
110 axum::routing::get(server_fn_handle).post(server_fn_handle),
111 )
112 .route(
113 "/content/{*fn_name}",
114 axum::routing::get(server_fn_handle).post(server_fn_handle),
115 )
116 .route(
117 "/domain/{*fn_name}",
118 axum::routing::get(server_fn_handle).post(server_fn_handle),
119 )
120 .leptos_routes_with_handler(
121 routes,
122 axum::routing::get(routes_handler), )
124 .route("/img", axum::routing::get(files::img_handler))
125 .route("/doc", axum::routing::get(files::doc_handler))
126 .route("/aux", axum::routing::get(files::aux_handler))
127 .fallback(file_and_error_handler)
128 .layer(auth_layer)
129 .layer(
130 tower_http::cors::CorsLayer::new()
131 .allow_methods([http::Method::GET, http::Method::POST])
132 .allow_origin(tower_http::cors::Any)
133 .allow_headers([http::header::COOKIE, http::header::SET_COOKIE]),
135 )
136 .layer(tower_http::trace::TraceLayer::new_for_http().make_span_with(SpanLayer));
137 let app: Router<()> = app.with_state(state);
138
139 if let Some(channel) = port_channel {
140 channel
141 .send(Some(addr.port()))
142 .expect("Error sending port address");
143 }
144
145 axum::serve(
148 listener,
149 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
150 )
151 .into_future()
152 .await
155 .unwrap_or_else(|e| panic!("{e}"));
156}
157
158async fn gl_cont(
159 extract::Query(params): extract::Query<flams_git::gl::auth::AuthRequest>,
160 extract::State(state): extract::State<ServerState>,
161 mut auth_session: axum_login::AuthSession<DBBackend>,
162) -> Result<axum::response::Response, StatusCode> {
163 let oauth = state.oauth.as_ref().unwrap_or_else(|| unreachable!());
164 let token = oauth
165 .callback(params)
166 .await
167 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
168 let gl = flams_git::gl::GLInstance::global()
169 .get()
170 .await
171 .unwrap_or_else(|| unreachable!());
172 let user = gl
173 .get_oauth_user(&token)
174 .await
175 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
176 if let Ok(Some(u)) = state.db.add_user(user, token.secret().clone()).await {
177 let _ = auth_session.login(&u).await;
178 }
179 Ok(Redirect::to("/dashboard").into_response())
180}
181
182async fn routes_handler(
183 auth_session: axum_login::AuthSession<DBBackend>,
184 extract::State(ServerState {
185 db, options, oauth, ..
186 }): extract::State<ServerState>,
187 request: http::Request<axum::body::Body>,
188) -> Result<impl IntoResponse, StatusCode> {
189 use futures::future::FutureExt;
190 let handler = leptos_axum::render_app_to_stream_with_context(
191 move || {
192 provide_context(auth_session.clone());
193 provide_context(db.clone());
194 provide_context(oauth.clone());
195 },
196 move || shell(options.clone()),
197 );
198 std::panic::AssertUnwindSafe(handler(request))
199 .catch_unwind()
200 .await
201 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
202}
203
204async fn server_fn_handle(
205 auth_session: axum_login::AuthSession<DBBackend>,
206 extract::State(ServerState {
207 db, options, oauth, ..
208 }): extract::State<ServerState>,
209 request: http::Request<axum::body::Body>,
210) -> impl IntoResponse {
211 leptos_axum::handle_server_fns_with_context(
212 move || {
213 provide_context(auth_session.clone());
214 provide_context(options.clone());
215 provide_context(db.clone());
216 provide_context(oauth.clone());
217 },
218 request,
219 )
220 .await
222}
223
224async fn file_and_error_handler(
225 uri: Uri,
226 state: extract::State<ServerState>,
227 request: http::Request<axum::body::Body>,
228) -> axum::response::Response {
229 if uri.path().contains("fonts/") {
230 use axum::http::header::ACCEPT_ENCODING;
231 let root = format!("{}/../fonts", &*state.0.options.site_root);
232 let mut parts = uri.into_parts();
233 parts.path_and_query = Some(
234 http::uri::PathAndQuery::from_str({
235 let old = parts.path_and_query.as_ref().expect("unreachable").as_str();
236 let i = old.find("fonts/").expect("unreachable");
237 &old[i + "fonts".len()..]
238 })
239 .expect("unreachable"),
240 );
241 let uri = http::uri::Uri::from_parts(parts).expect("valid");
242
243 let headers = request.headers();
244 let req = http::Request::builder().uri(uri);
245
246 let req = match headers.get(ACCEPT_ENCODING) {
247 Some(value) => req.header(ACCEPT_ENCODING, value),
248 None => req,
249 };
250
251 let req = req.body(axum::body::Body::empty()).unwrap();
252 match tower_http::services::ServeDir::new(root)
255 .precompressed_gzip()
256 .precompressed_br()
257 .oneshot(req)
258 .await
259 {
260 Ok(res) => res.into_response(),
261 Err(err) => (
262 StatusCode::INTERNAL_SERVER_ERROR,
263 format!("Something went wrong: {err}"),
264 )
265 .into_response(),
266 }
267 } else {
268 (leptos_axum::file_and_error_handler(shell))(uri, state, request).await
269 }
270 }
284
285#[derive(Clone)]
286struct SpanLayer;
287impl<A> tower_http::trace::MakeSpan<A> for SpanLayer {
288 fn make_span(&mut self, r: &http::Request<A>) -> tracing::Span {
289 tracing::span!(
291 parent:&*SERVER_SPAN,
292 tracing::Level::INFO,
293 "request",
294 method = %r.method(),
295 uri = %r.uri(),
296 version = ?r.version(),
297 )
298 }
299}
300
301#[derive(Clone, FromRef)]
302pub(crate) struct ServerState {
303 options: LeptosOptions,
304 db: DBBackend,
305 pub(crate) images: files::ImageStore,
306 pub(crate) oauth: Option<GitLabOAuth>,
307}
308
309impl ServerState {
310 async fn new() -> Self {
311 let leptos_cfg = Self::setup_leptos();
312 let redirect = Settings::get().gitlab_redirect_url.as_ref();
313 let oauth = if let Some(redirect) = redirect {
314 flams_git::gl::GLInstance::global()
315 .get()
316 .await
317 .and_then(|gl| gl.new_oauth(&format!("{redirect}/gitlab_login")).ok())
318 } else {
319 None
320 };
321 let db = DBBackend::new().in_current_span().await;
322 Self {
323 options: leptos_cfg.leptos_options,
324 db,
325 images: files::ImageStore::default(),
326 oauth,
327 }
328 }
329
330 fn setup_leptos() -> ConfFile {
331 let basepath = Self::get_basepath();
332 let mut leptos_cfg =
333 leptos::prelude::get_configuration(None).expect("Failed to get leptos config");
334 leptos_cfg.leptos_options.site_root = basepath.into();
335 leptos_cfg.leptos_options.hash_files = true;
345
346 let settings = Settings::get();
347 let ip = settings.ip;
348 let port = settings.port();
349 leptos_cfg.leptos_options.site_addr = std::net::SocketAddr::new(ip, port);
350 leptos_cfg
352 }
353
354 #[cfg(debug_assertions)]
355 fn get_basepath() -> String {
356 if Settings::get().lsp {
360 let Ok(p) = std::env::current_exe()
361 .expect("Error setting current web-dir path")
362 .parent()
363 .expect("Error setting current web-dir path")
364 .parent()
365 .expect("Error setting current web-dir path")
366 .join("web")
367 .canonicalize()
368 else {
369 panic!("Failed to canonicalize path");
370 };
371 p.display().to_string()
372 } else {
373 "target/web".to_string()
374 }
375 }
376 #[cfg(not(debug_assertions))]
377 fn get_basepath() -> String {
378 std::env::current_exe()
379 .ok()
380 .and_then(|p| p.parent().map(|p| p.join("web")))
381 .expect("Failed to determine executable path")
382 .display()
383 .to_string()
384 }
385}
386
387fn shell(options: LeptosOptions) -> impl IntoView {
388 ftml_component_utils::ssr_wrap(move || {
389 view! {
390 <!DOCTYPE html>
391 <html lang="en">
392 <head>
393 <meta charset="utf-8"/>
394 <meta name="viewport" content="width=device-width, initial-scale=1"/>
395 {
396 #[cfg(debug_assertions)]
397 {view!(<AutoReload options=options.clone() />)}
398 }
399 <HashedStylesheet id="leptos" options=options.clone()/>
400 <HydrationScripts options /><leptos_meta::MetaTags/>
402 </head>
403 <body>
404 <Main/>
405 </body>
406 </html>
407 }
408 })
409}