diff --git a/Dockerfile b/Dockerfile --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ COPY web/package.json web/package-lock.json ./ RUN npm ci COPY web/ . +ENV NEXT_PUBLIC_BASE_PATH=/__HAPPYVIEW_BP__ RUN npm run build FROM rust:1.93-bookworm AS builder @@ -34,9 +35,11 @@ COPY --from=builder /app/target/release/happyview /usr/local/bin/happyview COPY --from=builder /app/migrations /app/migrations COPY --from=frontend /app/web/out /srv/static +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh ENV STATIC_DIR=/srv/static EXPOSE 3000 -ENTRYPOINT ["happyview"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -e + +BP="${BASE_PATH:-}" +STATIC="${STATIC_DIR:-/srv/static}" +SENTINEL="/__HAPPYVIEW_BP__" +SENTINEL_DIR="__HAPPYVIEW_BP__" + +# Only run replacement if the sentinel directory exists (first boot) +if [ -d "${STATIC}/${SENTINEL_DIR}" ]; then + if [ -n "$BP" ]; then + # Validate: must start with / + case "$BP" in + /*) ;; + *) echo "ERROR: BASE_PATH must start with '/' (got: $BP)" >&2; exit 1 ;; + esac + # Strip trailing slash + BP="${BP%/}" + BP_DIR="${BP#/}" + + # Rename sentinel directory to match base path + mv "${STATIC}/${SENTINEL_DIR}" "${STATIC}/${BP_DIR}" + + # Replace sentinel string in static files + find "${STATIC}" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' \) \ + -exec sed -i "s|${SENTINEL}|${BP}|g" {} + + else + # No base path: move files from sentinel directory to static root + cp -a "${STATIC}/${SENTINEL_DIR}/." "${STATIC}/" + rm -rf "${STATIC}/${SENTINEL_DIR}" + + # Remove sentinel string from static files + find "${STATIC}" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' \) \ + -exec sed -i "s|${SENTINEL}||g" {} + + fi +fi + +exec happyview "$@" diff --git a/src/config.rs b/src/config.rs --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub relay_url: String, pub plc_url: String, pub static_dir: String, + pub base_path: Option, pub event_log_retention_days: u32, pub app_name: Option, pub logo_uri: Option, @@ -49,6 +50,16 @@ relay_url: env::var("RELAY_URL").unwrap_or_else(|_| "https://bsky.network".into()), plc_url: env::var("PLC_URL").unwrap_or_else(|_| "https://plc.directory".into()), static_dir: env::var("STATIC_DIR").unwrap_or_else(|_| "./web/out".into()), + base_path: env::var("BASE_PATH").ok().and_then(|s| { + let s = s.trim_end_matches('/').to_string(); + if s.is_empty() { + None + } else if !s.starts_with('/') { + panic!("BASE_PATH must start with '/' (got: {s})"); + } else { + Some(s) + } + }), event_log_retention_days: std::env::var("EVENT_LOG_RETENTION_DAYS") .ok() .and_then(|v| v.parse().ok()) @@ -80,6 +91,20 @@ .parse() .expect("invalid HOST/PORT") } + + pub fn effective_public_url(&self) -> String { + match &self.base_path { + Some(bp) => format!("{}{}", self.public_url.trim_end_matches('/'), bp), + None => self.public_url.clone(), + } + } + + pub fn url_with_base_path(&self, domain_url: &str) -> String { + match &self.base_path { + Some(bp) => format!("{}{}", domain_url.trim_end_matches('/'), bp), + None => domain_url.to_string(), + } + } } #[cfg(test)] @@ -103,6 +128,7 @@ "LOGO_URI", "TOS_URI", "POLICY_URI", + "BASE_PATH", ] { unsafe { env::remove_var(key); @@ -130,6 +156,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, @@ -285,5 +312,212 @@ } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); + } + + #[test] + #[serial] + fn from_env_base_path_none_by_default() { + unsafe { + clear_env(); + set_required_env(); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + fn from_env_base_path_read_from_env() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/hv"); + } + let config = Config::from_env(); + assert_eq!(config.base_path.as_deref(), Some("/hv")); + } + + #[test] + #[serial] + fn from_env_base_path_strips_trailing_slash() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/hv/"); + } + let config = Config::from_env(); + assert_eq!(config.base_path.as_deref(), Some("/hv")); + } + + #[test] + #[serial] + fn from_env_base_path_empty_becomes_none() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", ""); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + fn from_env_base_path_slash_only_becomes_none() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/"); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + #[should_panic(expected = "BASE_PATH must start with '/'")] + fn from_env_base_path_without_leading_slash_panics() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "hv"); + } + Config::from_env(); + } + + #[test] + fn effective_public_url_without_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com"); + } + + #[test] + fn effective_public_url_with_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com/hv"); + } + + #[test] + fn effective_public_url_trims_trailing_slash() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com/".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com/hv"); + } + + #[test] + fn url_with_base_path_appends() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: String::new(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!( + config.url_with_base_path("https://otherdomain.com"), + "https://otherdomain.com/hv" + ); + } + + #[test] + fn url_with_base_path_without_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: String::new(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!( + config.url_with_base_path("https://otherdomain.com"), + "https://otherdomain.com" + ); } } diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -411,7 +411,10 @@ // Build atrium-oauth client let dns = NativeDnsResolver::new(); - let callback_url = format!("{}/auth/callback", config.public_url.trim_end_matches('/')); + let callback_url = format!( + "{}/auth/callback", + config.effective_public_url().trim_end_matches('/') + ); let atrium_http = Arc::new(DefaultHttpClient::default()); let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { @@ -459,9 +462,9 @@ client_metadata: AtprotoClientMetadata { client_id: format!( "{}/oauth-client-metadata.json", - config.public_url.trim_end_matches('/') + config.effective_public_url().trim_end_matches('/') ), - client_uri: Some(config.public_url.clone()), + client_uri: Some(config.effective_public_url()), redirect_uris: vec![callback_url], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], @@ -526,10 +529,12 @@ continue; // Already registered above } - let domain_callback_url = format!("{}/auth/callback", domain.url.trim_end_matches('/')); + let domain_base_url = config.url_with_base_path(&domain.url); + let domain_callback_url = + format!("{}/auth/callback", domain_base_url.trim_end_matches('/')); let domain_client_id = format!( "{}/oauth-client-metadata.json", - domain.url.trim_end_matches('/') + domain_base_url.trim_end_matches('/') ); let domain_http = Arc::new(DefaultHttpClient::default()); @@ -549,7 +554,7 @@ match atrium_oauth::OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoClientMetadata { client_id: domain_client_id, - client_uri: Some(domain.url.clone()), + client_uri: Some(domain_base_url.clone()), redirect_uris: vec![domain_callback_url], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -132,11 +132,22 @@ resolve_domain, )); - Router::new() - .route("/health", get(health)) + let app_routes = Router::new() .nest("/admin", admin::admin_routes(state.clone())) .merge(domain_routes) - .fallback_service(serve_dir) + .fallback_service(serve_dir); + + let outer = if let Some(ref base_path) = state.config.base_path { + Router::new() + .route("/health", get(health)) + .nest(base_path, app_routes) + } else { + Router::new() + .route("/health", get(health)) + .merge(app_routes) + }; + + outer .layer(TraceLayer::new_for_http()) .layer( CorsLayer::new() @@ -166,8 +177,8 @@ req: axum::extract::Request, ) -> Json { let domain_url = crate::domain_middleware::extract_domain(&req) - .map(|d| d.url.clone()) - .unwrap_or_else(|| state.config.public_url.clone()); + .map(|d| state.config.url_with_base_path(&d.url)) + .unwrap_or_else(|| state.config.effective_public_url()); let pool = &state.db; let backend = state.db_backend; @@ -223,11 +234,12 @@ State(state): State, req: axum::extract::Request, ) -> Json { - let domain_url = crate::domain_middleware::extract_domain(&req) + let raw_domain_url = crate::domain_middleware::extract_domain(&req) .map(|d| d.url.clone()) .unwrap_or_else(|| state.config.public_url.clone()); + let domain_url = state.config.url_with_base_path(&raw_domain_url); - let oauth_client = state.oauth.get_for_domain(&domain_url); + let oauth_client = state.oauth.get_for_domain(&raw_domain_url); let mut metadata = serde_json::to_value(&oauth_client.client_metadata).unwrap_or_default(); // The `client_id` field in the response must exactly match the URL the diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -27,6 +27,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -30,6 +30,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/web/next.config.ts b/web/next.config.ts --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,11 +1,13 @@ import type { NextConfig } from "next"; const apiBase = process.env.API_URL || "http://localhost:3000"; +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || undefined; const nextConfig: NextConfig = { reactCompiler: true, trailingSlash: true, images: { unoptimized: true }, + basePath, }; if (process.env.NODE_ENV === "production") { diff --git a/src/auth/routes.rs b/src/auth/routes.rs --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -229,7 +229,13 @@ .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; if user_exists.is_none() { - return Ok((jar, Redirect::to("/login?error=not_authorized"))); + let login_url = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/login?error=not_authorized", bp)) + .unwrap_or_else(|| "/login?error=not_authorized".into()); + return Ok((jar, Redirect::to(&login_url))); } } @@ -250,8 +256,13 @@ None }; - // Use DB-stored redirect, or default to "/" - let redirect_url = redirect_url.unwrap_or_else(|| "/".into()); + let default_redirect = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/", bp)) + .unwrap_or_else(|| "/".into()); + let redirect_url = redirect_url.unwrap_or(default_redirect); tracing::debug!(redirect_url = %redirect_url, "redirecting after callback"); // Set the session cookie diff --git a/src/external_auth/routes.rs b/src/external_auth/routes.rs --- a/src/external_auth/routes.rs +++ b/src/external_auth/routes.rs @@ -130,8 +130,8 @@ // Build the backend callback URL for OpenID/OAuth return_to // This ensures the auth provider redirects back to the backend, not the frontend let domain_url = domain - .map(|d| d.0.url.clone()) - .unwrap_or_else(|| app_state.config.public_url.clone()); + .map(|d| app_state.config.url_with_base_path(&d.0.url)) + .unwrap_or_else(|| app_state.config.effective_public_url()); let callback_url = format!( "{}/external-auth/{}/callback", diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -492,6 +492,7 @@ relay_url: String::new(), plc_url: plc_url.to_string(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -632,6 +632,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -1073,6 +1073,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -98,6 +98,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -202,6 +202,7 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -54,6 +54,7 @@ relay_url: mock_url.clone(), plc_url: mock_url.clone(), static_dir: "./web/out".into(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -57,6 +57,8 @@ BulkActionResponse, } from "@/types/dead-letters" +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "" + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -78,7 +80,7 @@ headers["Content-Type"] = "application/json" } - const res = await fetch(path, { + const res = await fetch(`${BASE_PATH}${path}`, { ...options, headers: { ...headers, ...options?.headers }, credentials: "same-origin", @@ -239,7 +241,7 @@ params?: Record ): Promise { const search = params ? `?${new URLSearchParams(params)}` : "" - const res = await fetch(`/xrpc/${encodeURIComponent(method)}${search}`) + const res = await fetch(`${BASE_PATH}/xrpc/${encodeURIComponent(method)}${search}`) if (!res.ok) { const text = await res.text().catch(() => res.statusText) throw new ApiError(res.status, text) @@ -323,7 +325,7 @@ export async function uploadLogo(file: File) { const formData = new FormData() formData.append("file", file) - const res = await fetch("/admin/settings/logo", { + const res = await fetch(`${BASE_PATH}/admin/settings/logo`, { method: "PUT", body: formData, credentials: "same-origin", diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -41,7 +41,7 @@ async function init() { try { // Check if the user has a valid session cookie - const resp = await fetch("/auth/me", { credentials: "same-origin" }) + const resp = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/me`, { credentials: "same-origin" }) if (resp.ok) { const data = await resp.json() if (!cancelled && data.did) { @@ -67,7 +67,7 @@ const login = useCallback(async (handle: string) => { setError(null) - const resp = await fetch(`/auth/login?handle=${encodeURIComponent(handle)}`, { + const resp = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/login?handle=${encodeURIComponent(handle)}`, { credentials: "same-origin", }) @@ -83,7 +83,7 @@ const logout = useCallback(async () => { try { - await fetch("/auth/logout", { + await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/logout`, { method: "POST", credentials: "same-origin", }) diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx --- a/web/src/lib/config-context.tsx +++ b/web/src/lib/config-context.tsx @@ -23,7 +23,7 @@ const [error, setError] = useState(null) useEffect(() => { - fetch("/config") + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/config`) .then((res) => { if (!res.ok) throw new Error(`Config fetch failed: ${res.status}`) return res.json() diff --git a/packages/docs/docs/getting-started/configuration.md b/packages/docs/docs/getting-started/configuration.md --- a/packages/docs/docs/getting-started/configuration.md +++ b/packages/docs/docs/getting-started/configuration.md @@ -8,7 +8,8 @@ |----------|----------|---------|-------------| | `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) | | `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set | -| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below) | +| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below). Do **not** include the base path — see `BASE_PATH` | +| `BASE_PATH` | no | _(none)_ | Subpath prefix for mounting HappyView behind a reverse proxy (e.g. `/hv`). Must start with `/` and have no trailing slash. When set, all routes are served under this prefix and the dashboard is accessible at `https://example.com/hv/`. See [Reverse proxy subpath](production-deployment.md#reverse-proxy-subpath) | | `SESSION_SECRET` | no | dev default | Secret key for signing session cookies (at least 64 characters). **Must be set in production** | | `HOST` | no | `0.0.0.0` | Bind host | | `PORT` | no | `3000` | Bind port | @@ -45,6 +46,7 @@ # DATABASE_URL=postgres://happyview:happyview@localhost/happyview # Optional overrides +# BASE_PATH=/hv # HOST=0.0.0.0 # PORT=3000 # JETSTREAM_URL=wss://jetstream1.us-east.bsky.network diff --git a/packages/docs/docs/getting-started/production-deployment.md b/packages/docs/docs/getting-started/production-deployment.md --- a/packages/docs/docs/getting-started/production-deployment.md +++ b/packages/docs/docs/getting-started/production-deployment.md @@ -32,6 +32,54 @@ `PUBLIC_URL` is used to construct OAuth redirect URIs, so it must exactly match the URL users hit — including scheme. A mismatch breaks OAuth login. +## Reverse proxy subpath + +If you need HappyView to share a domain with other services, set `BASE_PATH` to mount it at a subpath. For example, to serve the dashboard at `https://example.com/hv/`: + +```sh +PUBLIC_URL=https://example.com +BASE_PATH=/hv +``` + +`PUBLIC_URL` should **not** include the base path — HappyView appends it automatically when constructing OAuth callbacks and other external URLs. + +The base path is applied at container startup without rebuilding the image, so prebuilt Docker images (including Railway deployments) work with any subpath. + +### XRPC endpoints + +ATProto clients expect XRPC endpoints at `/xrpc/*` on the domain root. When using a base path, configure your reverse proxy to rewrite `/xrpc/*` requests so they reach HappyView under its base path. Here's an example using Caddy: + +``` +example.com { + # Dashboard and API under the base path + handle /hv/* { + reverse_proxy happyview:3000 + } + + # Redirect bare /hv to /hv/ for cleaner URLs + handle /hv { + redir /hv/ permanent + } + + # ATProto XRPC — rewrite to base path before proxying + handle /xrpc/* { + uri prefix /hv + reverse_proxy happyview:3000 + } + + # Everything else goes to another service + handle { + reverse_proxy other-app:3001 + } +} +``` + +The `uri prefix /hv` directive prepends `/hv` to the request path before proxying, so `/xrpc/com.atproto.sync.getRecord` becomes `/hv/xrpc/com.atproto.sync.getRecord` — which matches HappyView's nested routes. + +### Health checks with a base path + +`GET /health` is always served at the domain root, even when `BASE_PATH` is set. This keeps load balancer probes working without routing changes. + ## Database SQLite is fine for small to medium instances and is the default. Switch to Postgres if you need: diff --git a/web/src/app/dashboard/about/page.tsx b/web/src/app/dashboard/about/page.tsx --- a/web/src/app/dashboard/about/page.tsx +++ b/web/src/app/dashboard/about/page.tsx @@ -25,7 +25,7 @@ const [error, setError] = useState(null) useEffect(() => { - fetch("/config", { credentials: "same-origin" }) + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/config`, { credentials: "same-origin" }) .then((r) => { if (!r.ok) throw new Error("Failed to load config") return r.json()