feat: Allow to host under subpath #145

This commit is contained in:
rustmailer
2026-03-12 01:55:14 +08:00
parent f3c46f97b9
commit 40eca89a75
13 changed files with 121 additions and 67 deletions
+30 -7
View File
@@ -37,8 +37,8 @@ use http::{HeaderValue, Method};
use poem::endpoint::EmbeddedFilesEndpoint;
use poem::listener::{Listener, TcpListener};
use poem::middleware::{CatchPanic, Compression, SetHeader};
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
use poem::{get, post};
use poem::{get, handler, post, IntoResponse};
use poem::{middleware::Cors, EndpointExt, Route, Server};
use public::oauth2::oauth2_callback;
use std::collections::HashSet;
use std::time::Duration;
@@ -115,7 +115,7 @@ pub async fn start_http_server() -> BichonResult<()> {
)
};
let route = Route::new()
let app_logic = Route::new()
.nest("/api-docs/swagger", swagger)
.nest("/api-docs/redoc", redoc)
.nest("/api-docs/explorer", openapi_explorer)
@@ -130,10 +130,10 @@ pub async fn start_http_server() -> BichonResult<()> {
"/assets",
EmbeddedFilesEndpoint::<FrontEndAssets>::new().with(cache_static()),
)
.at(
"/*",
EmbeddedFileEndpoint::<FrontEndAssets>::new("index.html"),
)
.at("/*", serve_index_with_base);
let route = Route::new()
.nest(&SETTINGS.bichon_base_url, app_logic)
.with(cors)
.with_if(SETTINGS.bichon_http_compression_enabled, Compression::new())
.with(CatchPanic::new());
@@ -158,3 +158,26 @@ pub async fn start_http_server() -> BichonResult<()> {
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
#[handler]
async fn serve_index_with_base() -> impl IntoResponse {
let mut html =
String::from_utf8_lossy(&FrontEndAssets::get("index.html").unwrap().data).to_string();
let raw_base = &SETTINGS.bichon_base_url;
let base_href = if raw_base.ends_with('/') {
raw_base.clone()
} else {
format!("{}/", raw_base)
};
let inject_content = format!(
r#"<base href="{}"><script>window.__BICHON_BASE__ = '{}';</script>"#,
base_href, raw_base
);
html = html.replace("<head>", &format!("<head>{}", inject_content));
poem::Response::builder()
.content_type("text/html; charset=utf-8")
.body(html)
}
+22
View File
@@ -74,6 +74,16 @@ pub struct Settings {
)]
pub bichon_public_url: String,
/// bichon base URL path (default: "/")
#[clap(
long,
default_value = "/",
env,
help = "Set the base UI path for bichon (e.g., '/bichon' or '/bichon/'). Must start with /",
value_parser = validate_base_url
)]
pub bichon_base_url: String,
/// CORS allowed origins (default: "*")
#[clap(
long,
@@ -381,6 +391,18 @@ impl Settings {
}
}
fn validate_base_url(s: &str) -> Result<String, String> {
if s == "/" {
return Ok(s.to_string());
}
if !s.starts_with('/') {
return Err(String::from(
"Base URL must start with '/' (e.g., '/bichon')",
));
}
Ok(s.to_string())
}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum CompressionAlgorithm {
#[clap(name = "none")]
+8 -8
View File
@@ -26,7 +26,7 @@ export interface MinimalAccount {
}
export const minimal_account_list = async () => {
const response = await axiosInstance.get<MinimalAccount[]>("/api/v1/minimal-account-list");
const response = await axiosInstance.get<MinimalAccount[]>("api/v1/minimal-account-list");
return response.data;
};
@@ -109,27 +109,27 @@ export interface AccountModel {
}
export const account_state = async (account_id: number) => {
const response = await axiosInstance.get<AccountRunningState>(`/api/v1/account-state/${account_id}`);
const response = await axiosInstance.get<AccountRunningState>(`api/v1/account-state/${account_id}`);
return response.data;
};
export const create_account = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/account", data);
const response = await axiosInstance.post("api/v1/account", data);
return response.data;
};
export const list_accounts = async () => {
const response = await axiosInstance.get<PaginatedResponse<AccountModel>>("/api/v1/accounts?desc=true");
const response = await axiosInstance.get<PaginatedResponse<AccountModel>>("api/v1/accounts?desc=true");
return response.data;
};
export const update_account = async (account_id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/account/${account_id}`, data);
const response = await axiosInstance.post(`api/v1/account/${account_id}`, data);
return response.data;
};
export const remove_account = async (account_id: number) => {
const response = await axiosInstance.delete(`/api/v1/account/${account_id}`);
const response = await axiosInstance.delete(`api/v1/account/${account_id}`);
return response.data;
};
@@ -152,11 +152,11 @@ export interface OAuth2Config {
}
export const autoconfig = async (email: string) => {
const response = await axiosInstance.get<AutoConfigResult>(`/api/v1/autoconfig/${email}`);
const response = await axiosInstance.get<AutoConfigResult>(`api/v1/autoconfig/${email}`);
return response.data;
};
export const access_assign = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data);
const response = await axiosInstance.post("api/v1/accounts/access/assignments", data);
return response.data;
};
+5 -1
View File
@@ -20,9 +20,13 @@
import { getToken } from "@/stores/authStore";
import axios from "axios";
const injectedBase = (window as any).__BICHON_BASE__;
const base_url = (injectedBase === "/" || !injectedBase) ? "" : injectedBase;
// Create an Axios instance
const baseURL = process.env.NODE_ENV === "production"
? "/" // Production: relative to the current domain
? base_url // Production: relative to the current domain
: "http://localhost:15630"; // Development: Poem's backend server
const axiosInstance = axios.create({
+2 -2
View File
@@ -33,12 +33,12 @@ export interface MailboxData {
}
export const list_mailboxes = async (accountId: number, remote: boolean) => {
const response = await axiosInstance.get<MailboxData[]>(`/api/v1/list-mailboxes/${accountId}?remote=${remote}`);
const response = await axiosInstance.get<MailboxData[]>(`api/v1/list-mailboxes/${accountId}?remote=${remote}`);
return response.data;
};
export const delete_mailbox = async (accountId: number, mailboxId: string) => {
const response = await axiosInstance.delete(`/api/v1/delete-mailbox/${accountId}/${mailboxId}`);
const response = await axiosInstance.delete(`api/v1/delete-mailbox/${accountId}/${mailboxId}`);
return response.data;
};
+7 -7
View File
@@ -29,7 +29,7 @@ export const list_messages = async (accountId: number, mailbox_id: number, page:
});
const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
`/api/v1/list-messages/${accountId}?${params.toString()}`
`api/v1/list-messages/${accountId}?${params.toString()}`
);
return response.data;
};
@@ -42,13 +42,13 @@ export const get_thread_messages = async (accountId: number, thread_id: number,
});
const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
`/api/v1/get-thread-messages/${accountId}?${params.toString()}`
`api/v1/get-thread-messages/${accountId}?${params.toString()}`
);
return response.data;
}
export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => {
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' });
const response = await axiosInstance.get(`api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, attachmentFileName);
};
@@ -83,17 +83,17 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
};
export const load_message = async (accountId: number, id: number) => {
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}/${id}`);
const response = await axiosInstance.get<MessageContentResponse>(`api/v1/message-content/${accountId}/${id}`);
return response.data;
};
export const delete_messages = async (payload: Record<string, number[]>) => {
const response = await axiosInstance.post("/api/v1/delete-messages", payload);
const response = await axiosInstance.post("api/v1/delete-messages", payload);
return response.data;
};
export const download_message = async (accountId: number, id: number) => {
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' });
const response = await axiosInstance.get(`api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, `${id}.eml`);
};
@@ -101,7 +101,7 @@ export const download_message = async (accountId: number, id: number) => {
export const restore_message = async (accountId: number, messageIds: number[]) => {
const response = await axiosInstance.post(`/api/v1/restore-messages/${accountId}`, {
const response = await axiosInstance.post(`api/v1/restore-messages/${accountId}`, {
message_ids: messageIds,
});
return response.data;
+6 -6
View File
@@ -21,28 +21,28 @@ import { OAuth2Entity } from "@/features/oauth2/data/schema";
import axiosInstance from "../axiosInstance";
export const get_oauth2_list = async () => {
const response = await axiosInstance.get<{ items: OAuth2Entity[] }>("/api/v1/oauth2-list");
const response = await axiosInstance.get<{ items: OAuth2Entity[] }>("api/v1/oauth2-list");
return response.data;
};
export const delete_oauth2 = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/oauth2/${id}`);
const response = await axiosInstance.delete(`api/v1/oauth2/${id}`);
return response.data;
};
export const create_oauth2 = async (data: Record<string, any>) => {
const response = await axiosInstance.post<OAuth2Entity>("/api/v1/oauth2", data);
const response = await axiosInstance.post<OAuth2Entity>("api/v1/oauth2", data);
return response.data;
};
export const update_oauth2 = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post<OAuth2Entity>(`/api/v1/oauth2/${id}`, data);
const response = await axiosInstance.post<OAuth2Entity>(`api/v1/oauth2/${id}`, data);
return response.data;
};
export const get_authorize_url = async (data: Record<string, any>) => {
const response = await axiosInstance.post('/api/v1/oauth2-authorize-url', data);
const response = await axiosInstance.post('api/v1/oauth2-authorize-url', data);
return response.data;
};
@@ -57,6 +57,6 @@ export interface OAuth2Tokens {
}
export const get_oauth2_tokens = async (accountId: number) => {
const response = await axiosInstance.get<OAuth2Tokens>(`/api/v1/oauth2-tokens/${accountId}`);
const response = await axiosInstance.get<OAuth2Tokens>(`api/v1/oauth2-tokens/${accountId}`);
return response.data;
};
+4 -4
View File
@@ -21,7 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
import { EmailEnvelope, PaginatedResponse } from "..";
export const search_messages = async (payload: Record<string, any>) => {
const response = await axiosInstance.post<PaginatedResponse<EmailEnvelope>>("/api/v1/search-messages", payload);
const response = await axiosInstance.post<PaginatedResponse<EmailEnvelope>>("api/v1/search-messages", payload);
return response.data;
};
@@ -31,18 +31,18 @@ export interface TagCount {
}
export const get_tags = async () => {
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
const response = await axiosInstance.get<TagCount[]>("api/v1/all-tags");
return response.data;
}
export const update_tags = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/update-tags", data);
const response = await axiosInstance.post("api/v1/update-tags", data);
return response.data;
};
export const get_contacts = async () => {
const response = await axiosInstance.get<string[]>("/api/v1/all-contacts");
const response = await axiosInstance.get<string[]>("api/v1/all-contacts");
return response.data;
}
+7 -7
View File
@@ -37,7 +37,7 @@ interface Notifications {
}
export const get_notifications = async () => {
const response = await axiosInstance.get<Notifications>(`/api/v1/notifications`);
const response = await axiosInstance.get<Notifications>(`api/v1/notifications`);
return response.data;
};
@@ -118,22 +118,22 @@ export type ServerConfigurations = {
}
export const get_dashboard_stats = async () => {
const response = await axiosInstance.get<DashboardStats>(`/api/v1/dashboard-stats`);
const response = await axiosInstance.get<DashboardStats>(`api/v1/dashboard-stats`);
return response.data;
};
export const list_proxy = async () => {
const response = await axiosInstance.get<Proxy[]>(`/api/v1/list-proxy`);
const response = await axiosInstance.get<Proxy[]>(`api/v1/list-proxy`);
return response.data;
};
export const delete_proxy = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/proxy/${id}`);
const response = await axiosInstance.delete(`api/v1/proxy/${id}`);
return response.data;
};
export const update_proxy = async (id: number, url: string) => {
const response = await axiosInstance.post(`/api/v1/proxy/${id}`, url, {
const response = await axiosInstance.post(`api/v1/proxy/${id}`, url, {
headers: {
"Content-Type": "text/plain",
},
@@ -142,7 +142,7 @@ export const update_proxy = async (id: number, url: string) => {
};
export const add_proxy = async (url: string) => {
const response = await axiosInstance.post(`/api/v1/proxy`, url, {
const response = await axiosInstance.post(`api/v1/proxy`, url, {
headers: {
"Content-Type": "text/plain",
},
@@ -152,6 +152,6 @@ export const add_proxy = async (url: string) => {
export const get_system_configurations = async () => {
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
const response = await axiosInstance.get<ServerConfigurations>(`api/v1/system-configurations`);
return response.data;
};
+19 -19
View File
@@ -125,17 +125,17 @@ export interface MinimalUser {
export const login = async (data: Record<string, any>) => {
const response = await axiosInstance.post<LoginResult>(`/api/login`, data);
const response = await axiosInstance.post<LoginResult>(`api/login`, data);
return response.data;
};
export const reset_admin_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-admin-token");
const response = await axiosInstance.post("api/v1/reset-admin-token");
return response.data;
};
export const reset_admin_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-admin-password", password, {
const response = await axiosInstance.post("api/v1/reset-admin-password", password, {
headers: {
"Content-Type": "text/plain",
},
@@ -144,89 +144,89 @@ export const reset_admin_password = async (password: string) => {
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
const response = await axiosInstance.get<AccessToken[]>("api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
const response = await axiosInstance.post("api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
const response = await axiosInstance.post(`api/v1/access-token/${token}`, data);
return response.data;
}
export const remove_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
const response = await axiosInstance.delete(`api/v1/access-token/${token}`);
return response.data;
}
export const list_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-roles");
const response = await axiosInstance.get<UserRole[]>("api/v1/list-roles");
return response.data;
};
export const remove_role = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/roles/${id}`);
const response = await axiosInstance.delete(`api/v1/roles/${id}`);
return response.data;
};
export const create_role = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/roles", data);
const response = await axiosInstance.post("api/v1/roles", data);
return response.data;
};
export const update_role = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/roles/${id}`, data);
const response = await axiosInstance.post(`api/v1/roles/${id}`, data);
return response.data;
};
export const list_users = async () => {
const response = await axiosInstance.get<User[]>("/api/v1/list-users");
const response = await axiosInstance.get<User[]>("api/v1/list-users");
return response.data;
};
export const list_minimal_users = async () => {
const response = await axiosInstance.get<MinimalUser[]>("/api/v1/minimal-user-list");
const response = await axiosInstance.get<MinimalUser[]>("api/v1/minimal-user-list");
return response.data;
};
export const list_account_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-account-roles");
const response = await axiosInstance.get<UserRole[]>("api/v1/list-account-roles");
return response.data;
};
export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
const response = await axiosInstance.delete(`api/v1/users/${id}`);
return response.data;
};
export const create_user = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/users", data);
const response = await axiosInstance.post("api/v1/users", data);
return response.data;
};
export const update_user = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/users/${id}`, data);
const response = await axiosInstance.post(`api/v1/users/${id}`, data);
return response.data;
};
export const get_user_tokens = async (id: number) => {
const response = await axiosInstance.get<AccessToken[]>(`/api/v1/user-tokens/${id}`);
const response = await axiosInstance.get<AccessToken[]>(`api/v1/user-tokens/${id}`);
return response.data;
};
export const get_current_user = async () => {
const response = await axiosInstance.get<User>("/api/v1/current-user");
const response = await axiosInstance.get<User>("api/v1/current-user");
return response.data;
};
+7 -6
View File
@@ -27,12 +27,14 @@ import { Separator } from "./ui/separator";
export default function APIDocs() {
const { t } = useTranslation()
const base_url = (window as any).__BICHON_BASE__ || '';
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
{ name: t('apiDocs.openAPIExplorer'), path: "/api-docs/explorer" },
{ name: t('apiDocs.scalar'), path: "/api-docs/scalar" },
{ name: t('apiDocs.downloadSpecYAML'), path: "/api-docs/spec.yaml" }
{ name: t('apiDocs.swaggerUI'), path: `${base_url}/api-docs/swagger` },
{ name: t('apiDocs.reDoc'), path: `${base_url}/api-docs/redoc` },
{ name: t('apiDocs.openAPIExplorer'), path: `${base_url}/api-docs/explorer` },
{ name: t('apiDocs.scalar'), path: `${base_url}/api-docs/scalar` },
{ name: t('apiDocs.downloadSpecYAML'), path: `${base_url}/api-docs/spec.yaml` }
];
const handleCardClick = (path: string) => {
// Open in new tab
@@ -41,7 +43,6 @@ export default function APIDocs() {
return (
<>
<FixedHeader />
<Main>
<div className='mb-2 flex items-center justify-between space-y-2 flex-wrap gap-x-4'>
+3
View File
@@ -121,9 +121,12 @@ const queryClient = new QueryClient({
}),
})
const basepath = (window as any).__BICHON_BASE__ || '/';
console.log('Current Basepath:', basepath);
// Create a new router instance
const router = createRouter({
routeTree,
basepath,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
+1
View File
@@ -5,6 +5,7 @@ import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
// https://vite.dev/config/
export default defineConfig({
base: '',
plugins: [react(), TanStackRouterVite()],
resolve: {
alias: {