This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -3,6 +3,7 @@ mod auth;
mod consts;
mod data;
mod features;
mod licensing;
mod metrics;
mod og_middleware;
pub mod parsing;
@ -13,15 +14,17 @@ pub mod utils;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context};
use axum::middleware;
use axum::routing::{any, get, post};
use axum::routing::{any, get, patch, post};
use axum::Router;
use clap::Parser;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::info;
@ -52,7 +55,7 @@ struct Cli {
#[arg(long)]
tiles: PathBuf,
/// Path to the frontend dist directory
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
#[arg(long)]
dist: Option<PathBuf>,
@ -70,11 +73,11 @@ struct Cli {
/// PocketBase superuser email (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_EMAIL")]
pocketbase_admin_email: Option<String>,
pocketbase_admin_email: String,
/// PocketBase superuser password (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
pocketbase_admin_password: Option<String>,
pocketbase_admin_password: String,
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
#[arg(long, env = "OLLAMA_URL")]
@ -84,13 +87,41 @@ struct Cli {
#[arg(long, env = "OLLAMA_MODEL")]
ollama_model: String,
/// R5 routing service URL for all travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL")]
r5_url: Option<String>,
/// Path to precomputed travel times directory (contains mode subdirs with parquet files)
#[arg(long, env = "TRAVEL_TIMES")]
travel_times: PathBuf,
/// Google Maps API key for Street View metadata lookups
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
google_maps_api_key: String,
/// Stripe secret key for checkout sessions
#[arg(long, env = "STRIPE_SECRET_KEY")]
stripe_secret_key: String,
/// Stripe webhook signing secret for verifying webhook signatures
#[arg(long, env = "STRIPE_WEBHOOK_SECRET")]
stripe_webhook_secret: String,
/// Stripe Coupon ID applied when a referral code is used
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
stripe_referral_coupon_id: String,
/// Google OAuth client ID for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
google_oauth_client_id: String,
/// Google OAuth client secret for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
google_oauth_client_secret: String,
/// Apple OAuth client ID for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_ID")]
apple_oauth_client_id: String,
/// Apple OAuth client secret for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_SECRET")]
apple_oauth_client_secret: String,
}
#[tokio::main]
@ -212,19 +243,23 @@ async fn main() -> anyhow::Result<()> {
let poi_category_groups = poi_data.category_groups()?;
// Read index.html at startup for crawler OG injection
let (frontend_dist, index_html) = if let Some(dist) = cli.dist {
// Read index.html at startup for crawler OG injection (only when --dist is provided)
let index_html = if let Some(ref dist) = cli.dist {
let index_path = dist.join("index.html");
let html = std::fs::read_to_string(&index_path)
.with_context(|| format!("Failed to read {}", index_path.display()))?;
info!("Loaded index.html for OG injection");
(Some(dist), Some(html))
Some(html)
} else {
info!("No --dist provided, static serving and OG injection disabled");
(None, None)
info!("No --dist provided; static serving and OG injection disabled");
None
};
let http_client = reqwest::Client::new();
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.context("Failed to build HTTP client")?;
info!("Screenshot service configured: {}", cli.screenshot_url);
@ -247,23 +282,46 @@ async fn main() -> anyhow::Result<()> {
info!("PocketBase configured: {}", cli.pocketbase_url);
if let (Some(ref email), Some(ref password)) =
(&cli.pocketbase_admin_email, &cli.pocketbase_admin_password)
{
pocketbase::ensure_collections(&http_client, &cli.pocketbase_url, email, password).await?;
} else {
info!("PocketBase admin credentials not set — skipping collection auto-creation");
}
pocketbase::ensure_collections(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
)
.await?;
pocketbase::ensure_oauth_providers(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
&cli.public_url,
&cli.google_oauth_client_id,
&cli.google_oauth_client_secret,
&cli.apple_oauth_client_id,
&cli.apple_oauth_client_secret,
)
.await?;
info!(
"Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model
);
if let Some(ref url) = cli.r5_url {
info!("R5 routing service configured: {}", url);
} else {
info!("R5 routing service not configured (travel time queries disabled)");
let tt_path = &cli.travel_times;
if !tt_path.exists() {
bail!(
"Travel times directory not found: {}",
tt_path.display()
);
}
info!("Loading travel time data from {}", tt_path.display());
let travel_time_store = {
let store = data::TravelTimeStore::load(tt_path, 50)?;
info!(
modes = store.available_modes.len(),
"Travel time store loaded"
);
Arc::new(store)
};
let token_cache = Arc::new(auth::TokenCache::new());
@ -286,19 +344,30 @@ async fn main() -> anyhow::Result<()> {
index_html,
http_client,
pocketbase_url: cli.pocketbase_url,
pocketbase_admin_email: cli.pocketbase_admin_email,
pocketbase_admin_password: cli.pocketbase_admin_password,
ollama_url: cli.ollama_url,
ollama_model: cli.ollama_model,
r5_url: cli.r5_url,
travel_time_store,
token_cache,
ai_filters_schema,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key,
stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
.allow_origin(
state
.public_url
.parse::<axum::http::HeaderValue>()
.expect("public_url must be a valid header value"),
)
.allow_methods(AllowMethods::mirror_request())
.allow_headers(AllowHeaders::mirror_request())
.allow_credentials(true);
let state_features = state.clone();
let state_hexagons = state.clone();
@ -319,6 +388,15 @@ async fn main() -> anyhow::Result<()> {
let state_short_url = state.clone();
let state_ai_filters = state.clone();
let state_streetview = state.clone();
let state_subscription = state.clone();
let state_newsletter = state.clone();
let state_travel_modes = state.clone();
let state_checkout = state.clone();
let state_stripe_webhook = state.clone();
let state_pricing = state.clone();
let state_invites_create = state.clone();
let state_invite_get = state.clone();
let state_redeem_invite = state.clone();
let api = Router::new()
.route(
@ -327,11 +405,11 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/hexagons",
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
get(move |ext, query| routes::get_hexagons(state_hexagons.clone(), ext, query)),
)
.route(
"/api/postcodes",
get(move |query| routes::get_postcodes(state_postcodes.clone(), query)),
get(move |ext, query| routes::get_postcodes(state_postcodes.clone(), ext, query)),
)
.route(
"/api/postcode/{postcode}",
@ -349,19 +427,23 @@ async fn main() -> anyhow::Result<()> {
"/api/places",
get(move |query| routes::get_places(state_places.clone(), query)),
)
.route(
"/api/travel-modes",
get(move || routes::get_travel_modes(state_travel_modes.clone())),
)
.route(
"/api/hexagon-properties",
get(move |query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), query)
get(move |ext, query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), ext, query)
}),
)
.route(
"/api/hexagon-stats",
get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)),
get(move |ext, query| routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)),
)
.route(
"/api/postcode-stats",
get(move |query| routes::get_postcode_stats(state_postcode_stats.clone(), query)),
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
)
.route(
"/api/screenshot",
@ -369,12 +451,14 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/export",
get(move |query| routes::get_export(state_export.clone(), query)),
get(move |ext, query| routes::get_export(state_export.clone(), ext, query))
.layer(ConcurrencyLimitLayer::new(3)),
)
.route("/api/me", get(routes::get_me))
.route(
"/api/area-summary",
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
post(move |body| routes::post_area_summary(state_area_summary.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/shorten",
@ -382,12 +466,54 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/ai-filters",
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body)),
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/streetview",
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
)
.route(
"/api/subscription",
patch(move |ext, body| {
routes::patch_subscription(state_subscription.clone(), ext, body)
}),
)
.route(
"/api/newsletter",
patch(move |ext, body| {
routes::patch_newsletter(state_newsletter.clone(), ext, body)
}),
)
.route(
"/api/pricing",
get(move || routes::get_pricing(state_pricing.clone())),
)
.route(
"/api/checkout",
post(move |ext, body| routes::post_checkout(state_checkout.clone(), ext, body))
.layer(ConcurrencyLimitLayer::new(10)),
)
.route(
"/api/stripe-webhook",
post(move |headers, body| {
routes::post_stripe_webhook(state_stripe_webhook.clone(), headers, body)
}),
)
.route(
"/api/invites",
post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
)
.route(
"/api/invite/{code}",
get(move |ext, path| routes::get_invite(state_invite_get.clone(), ext, path)),
)
.route(
"/api/redeem-invite",
post(move |ext, body| {
routes::post_redeem_invite(state_redeem_invite.clone(), ext, body)
}),
)
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
@ -396,6 +522,7 @@ async fn main() -> anyhow::Result<()> {
// Add tile routes
let reader_tile = tile_reader.clone();
let reader_style = tile_reader.clone();
let public_url_tiles = state.public_url.clone();
let api = api
.route(
"/api/tiles/{z}/{x}/{y}",
@ -403,8 +530,9 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/tiles/style.json",
get(move |headers, query| {
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
get(move |query| {
let pu = public_url_tiles.clone();
routes::get_style(axum::extract::State(reader_style.clone()), pu, query)
}),
)
.route("/health", get(|| async { "ok" }))
@ -417,15 +545,13 @@ async fn main() -> anyhow::Result<()> {
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
);
let app = if let Some(ref dist) = frontend_dist {
let app = if let Some(ref dist) = cli.dist {
api.fallback_service(
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
)
} else {
api
};
let app = app
}
.layer(middleware::from_fn(metrics::track_metrics))
.layer(middleware::from_fn(auth::auth_middleware))
.layer(middleware::from_fn(