use std::sync::Arc;
use actix_web::{
HttpRequest, HttpResponse, Responder, cookie::Cookie, delete, get, post,
web::{self, Data, Form, Path, Query},
};
use log::{error, info};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use serde::Deserialize;
use corium::{
auth::{HurlTest, Session, Ticket, User, ADMIN_API_KEY_COOKIE, SESSION_COOKIE},
code::{Code, CodeVersion, DEFAULT_KOTLIN_CODE, Lang, TestVersion},
compiler::Compiler,
config::Server,
runner::Runner,
store::DB,
};
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("")
.service(index)
.service(features_page)
.service(login_page)
.service(login_handler)
.service(signup_ticket_page)
.service(signup_ticket_handler)
.service(signup_page)
.service(signup_handler)
.service(logout_handler)
.service(dashboard)
.service(list_endpoints)
.service(create_endpoint_form)
.service(edit_endpoint_form)
.service(create_endpoint)
.service(update_endpoint)
.service(delete_endpoint)
.service(test_endpoint)
.service(save_code_version)
.service(list_code_versions)
.service(get_code_version)
.service(save_test_version)
.service(list_test_versions)
.service(get_test_version)
.service(settings_page)
.service(update_password)
.service(admin_tickets_page)
.service(create_ticket_handler)
.service(delete_ticket_handler)
.service(admin_login_page)
.service(admin_login_handler)
.service(admin_logout_handler)
.service(serve_wasm)
.service(run_endpoint)
.service(list_endpoints_api)
.service(health)
.service(up)
);
}
async fn get_user_id_from_request(req: &HttpRequest, db: &DB) -> Option<String> {
let cookie = req.cookie(SESSION_COOKIE)?;
let token = cookie.value().to_string();
let session = db.get_session(&token).await?;
if session.is_expired() {
let _ = db.delete_session(&token).await;
return None;
}
Some(session.user_id)
}
async fn get_user_from_request(req: &HttpRequest, db: &DB) -> Option<User> {
let user_id = get_user_id_from_request(req, db).await?;
db.get_user_by_id(&user_id).await
}
fn validate_admin_api_key(req: &HttpRequest, config: &Server) -> bool {
if let Some(cookie) = req.cookie(ADMIN_API_KEY_COOKIE) {
return cookie.value() == config.admin_api_key();
}
if let Some(header) = req.headers().get("X-Admin-API-Key") {
if let Ok(val) = header.to_str() {
return val == config.admin_api_key();
}
}
false
}
fn html_escape(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn format_timestamp(ts: u64) -> String {
let secs = ts as i64;
let days_since_epoch = secs / 86400;
let time_secs = secs % 86400;
let hours = time_secs / 3600;
let minutes = (time_secs % 3600) / 60;
let year = 1970;
let month = 1;
let day = 1 + days_since_epoch;
format!("{}-{:02}-{:02} {:02}:{:02}", year, month, day, hours, minutes)
}
fn set_session_cookie(token: &str, max_age: u64) -> Cookie<'static> {
Cookie::build(SESSION_COOKIE, token.to_string())
.path("/")
.http_only(true)
.max_age(actix_web::cookie::time::Duration::seconds(max_age as i64))
.same_site(actix_web::cookie::SameSite::Lax)
.finish()
}
fn clear_session_cookie() -> Cookie<'static> {
Cookie::build(SESSION_COOKIE, "")
.path("/")
.http_only(true)
.max_age(actix_web::cookie::time::Duration::seconds(0))
.same_site(actix_web::cookie::SameSite::Lax)
.finish()
}
fn set_admin_cookie(key: &str) -> Cookie<'static> {
Cookie::build(ADMIN_API_KEY_COOKIE, key.to_string())
.path("/")
.http_only(true)
.max_age(actix_web::cookie::time::Duration::days(1))
.same_site(actix_web::cookie::SameSite::Lax)
.finish()
}
fn clear_admin_cookie() -> Cookie<'static> {
Cookie::build(ADMIN_API_KEY_COOKIE, "")
.path("/")
.http_only(true)
.max_age(actix_web::cookie::time::Duration::seconds(0))
.same_site(actix_web::cookie::SameSite::Lax)
.finish()
}
fn layout(title: &str, content: Markup, user: Option<&User>) -> Markup {
let nav_content = if let Some(_u) = user {
html! {
div class="flex justify-between items-center w-100" {
a href="/" class="link orange f4 fw6" { "Corium" }
div class="flex gap3 items-center" {
a href="/dashboard" class="link near-white f6" { "Dashboard" }
a href="/endpoints" class="link near-white f6" { "Endpoints" }
a href="/settings" class="link near-white f6" { "Settings" }
a href="/logout" class="link hot-pink f6 ml2" { "Logout" }
}
}
}
} else {
html! {
div class="flex justify-between items-center w-100" {
a href="/" class="link orange f4 fw6" { "Corium" }
div class="flex gap3" {
a href="/login" class="link near-white f6" { "Login" }
a href="/signup" class="link near-white f6" { "Sign Up" }
}
}
}
};
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8" {}
meta name="viewport" content="width=device-width, initial-scale=1" {}
title { (title) }
link rel="stylesheet" href="/assets/t.css" {}
script src="/assets/h.js" {}
script src="/assets/hx-response-targets.js" {}
style {
(PreEscaped(r#"
* { font-family: 'Courier New', Courier, monospace; }
.min-h5 { min-height: 16rem; }
.gap2 { gap: 0.5rem; }
.gap3 { gap: 1rem; }
.code-editor {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.5;
tab-size: 4;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
background: #0a0a0a;
color: #ff6300;
}
.output-panel {
background: #0d0d0d;
}
input, select, textarea {
background: #0a0a0a;
color: #ff6300;
border-color: #333;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #ff6300 !important;
}
input::placeholder, textarea::placeholder {
color: #555 !important;
}
table { border-collapse: collapse; }
thead tr { background: #1a1a1a; }
trix-toolbar { margin-bottom: 0.5rem; }
"#))
}
}
body class="monospace bg-near-black near-white" {
nav class="pa3" {
(nav_content)
}
main {
(content)
}
}
}
}
}
fn landing_layout(title: &str, content: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8" {}
meta name="viewport" content="width=device-width, initial-scale=1" {}
title { (title) }
link rel="stylesheet" href="/assets/t.css" {}
style {
(PreEscaped(r#"
* { font-family: 'Courier New', Courier, monospace; }
.gap2 { gap: 0.5rem; }
.gap3 { gap: 1rem; }
input, select, textarea {
background: #0a0a0a;
color: #ff6300;
border-color: #333;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #ff6300 !important;
}
input::placeholder, textarea::placeholder {
color: #555 !important;
}
"#))
}
}
body class="monospace bg-near-black near-white" {
nav class="pa3" {
div class="flex justify-between items-center mw8 center" {
a href="/" class="link orange f4 fw6" { "Corium" }
div class="flex gap3" {
a href="/login" class="link light-silver f6" { "Login" }
a href="/signup" class="f6 link br2 ph2 pv1 dib orange ba b--orange" { "Sign Up" }
}
}
}
main {
(content)
}
footer class="pa4 mt5" {
div class="mw8 center tc" {
p class="f6 light-silver" { "Corium" }
}
}
}
}
}
}
// --- Landing Page ---
#[get("/")]
async fn index(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user = get_user_from_request(&req, &db).await;
if user.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/dashboard"))
.finish();
}
let content = html! {
section class="pt5 pb5 ph3" {
div class="mw7 center" {
h1 class="f-subheadline-l f1 fw7 mb3 orange lh-solid" { "Corium" }
p class="f4 fw3 light-silver mb4 lh-copy" {
"Deploy WASM endpoints in seconds."
br {}
"Write in Kotlin, compile to WebAssembly, run anywhere."
}
div class="flex gap3" {
a href="/signup" class="f5 link br2 ph3 pv2 dib orange ba b--orange" { "Get Started" }
a href="/features" class="f5 link br2 ph3 pv2 dib light-silver" { "Learn more" }
}
}
}
section class="pb5 ph3" {
div class="mw7 center" {
p class="f3 fw6 mb3 near-white" { "Ready to deploy?" }
p class="f5 light-silver mb4 lh-copy" {
"Sign up with an invite code and start deploying WASM endpoints."
}
a href="/signup" class="f5 link br2 ph3 pv2 dib orange ba b--orange" { "Sign Up" }
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(landing_layout("Corium - Deploy WASM Endpoints", content).into_string())
}
// --- Features Page ---
#[get("/features")]
async fn features_page() -> impl Responder {
let content = html! {
section class="pt2 pb1 ph3" {
div class="mw7 center" {
h1 class="f3 fw7 mb1 orange lh-solid" { "What is Corium?" }
p class="f6 fw3 light-silver mb2 lh-copy" {
"Corium lets you write code, compile it to WebAssembly, and deploy it as an HTTP endpoint — all in seconds."
}
}
}
section class="pb2 ph3" {
div class="mw7 center" {
h2 class="f4 fw6 mb1 near-white" { "Write in Kotlin, Deploy as WASM" }
p class="f6 light-silver lh-copy" {
"Write endpoint logic in Kotlin. Corium compiles it to WebAssembly — no containers, no cold starts, just your code ready to serve."
}
}
}
section class="pb2 ph3" {
div class="mw7 center" {
h2 class="f4 fw6 mb1 near-white" { "Instant Endpoints" }
p class="f6 light-silver lh-copy" {
"Every deployment becomes a live HTTP endpoint. Define your path, write your logic, hit deploy — available immediately at a predictable URL."
}
}
}
section class="pb2 ph3" {
div class="mw7 center" {
h2 class="f4 fw6 mb1 near-white" { "Built-in Testing" }
p class="f6 light-silver lh-copy" {
"Test endpoints right from the editor. Define request parameters, hit run, see the JSON response instantly."
}
}
}
section class="pb2 ph3" {
div class="mw7 center" {
h2 class="f4 fw6 mb1 near-white" { "Simple Auth & Access Control" }
p class="f6 light-silver lh-copy" {
"Sign up with an invite code, create endpoints, manage everything from a clean dashboard. Ticket-based invitations keep the platform focused."
}
}
}
section class="pb2 ph3" {
div class="mw7 center" {
h2 class="f4 fw6 mb1 near-white" { "Lightweight & Self-Hosted" }
p class="f6 light-silver lh-copy" {
"A single binary backed by SQLite. No Kubernetes, no message queues, no microservice sprawl. Deploy it anywhere."
}
}
}
section class="pb2 ph3" {
div class="mw7 center tc" {
a href="/signup" class="f5 link br2 ph3 pv2 dib orange ba b--orange" { "Create an Account" }
}
}
};
let full_page = html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8" {}
meta name="viewport" content="width=device-width, initial-scale=1" {}
title { "Features - Corium" }
link rel="stylesheet" href="/assets/t.css" {}
style {
(PreEscaped(r#"
* { font-family: 'Courier New', Courier, monospace; }
.gap2 { gap: 0.5rem; }
.gap3 { gap: 1rem; }
input, select, textarea { background: #0a0a0a; color: #ff6300; border-color: #333; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #ff6300 !important; }
input::placeholder, textarea::placeholder { color: #555 !important; }
html, body { height: 100%; overflow: hidden; }
"#))
}
}
body class="monospace bg-near-black near-white" {
nav class="pa3" {
div class="flex justify-between items-center mw8 center" {
a href="/" class="link orange f4 fw6" { "Corium" }
div class="flex gap3" {
a href="/login" class="link light-silver f6" { "Login" }
a href="/signup" class="f6 link br2 ph2 pv1 dib orange ba b--orange" { "Sign Up" }
}
}
}
main {
(content)
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(full_page.into_string())
}
// --- Auth Pages ---
#[get("/login")]
async fn login_page(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user = get_user_from_request(&req, &db).await;
if user.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/dashboard"))
.finish();
}
let content = html! {
div class="pa4 flex justify-center" {
div class="mw7 w-100" {
h1 class="f2 fw6 mb4 tc orange" { "Log In" }
form method="post" action="/login" class="ba b--dark-gray pa4 bg-near-black br2 " {
div class="mb3" {
label class="f6 b db mb2 near-white" for="email" { "Email" }
input
class="input-reset ba b--orange pa2 db w-100"
type="email" name="email" id="email" required placeholder="you@example.com" {}
}
div class="mb4" {
label class="f6 b db mb2 near-white" for="password" { "Password" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="password" id="password" required placeholder="Your password" {}
}
button type="submit" class="w-100 f5 link dim br2 ph3 pv3 dib near-black bg-orange pointer" {
"Log In"
}
}
p class="f6 tc mt3 light-silver" {
"Don't have an account? "
a href="/signup" class="link hot-pink underline" { "Sign up" }
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Log In - Corium", content, None).into_string())
}
#[derive(Deserialize)]
struct LoginForm {
email: String,
password: String,
}
#[post("/login")]
async fn login_handler(db: Data<Arc<DB>>, form: Form<LoginForm>) -> impl Responder {
let form = form.into_inner();
let user = match db.get_user_by_email(&form.email).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login?error=invalid"))
.finish();
}
};
if !user.verify_password(&form.password) {
return HttpResponse::Found()
.append_header(("Location", "/login?error=invalid"))
.finish();
}
let session = Session::new(&user.id);
let max_age = session.cookie_max_age_secs();
let token = session.token.clone();
if db.save_session(&session).await.is_err() {
return HttpResponse::InternalServerError().body("Failed to create session");
}
info!("User {} logged in", user.email);
HttpResponse::Found()
.cookie(set_session_cookie(&token, max_age))
.append_header(("Location", "/dashboard"))
.finish()
}
#[get("/signup/ticket")]
async fn signup_ticket_page() -> impl Responder {
let content = html! {
div class="pa4 flex justify-center" {
div class="mw7 w-100" {
h1 class="f2 fw6 mb4 tc orange" { "Get Invite Code" }
form method="post" action="/signup/ticket" class="ba b--dark-gray pa4 bg-near-black br2 " {
div class="mb4" {
label class="f6 b db mb2 near-white" for="api_key" { "Admin API Key" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="api_key" id="api_key" required placeholder="Enter the admin API key" {}
small class="f6 orange db mt1" { "Contact your administrator for the API key" }
}
button type="submit" class="w-100 f5 link dim br2 ph3 pv3 dib near-black bg-orange pointer" {
"Get Invite Code"
}
}
p class="f6 tc mt3 light-silver" {
"Already have a code? "
a href="/signup" class="link hot-pink underline" { "Sign up" }
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Get Invite Code - Corium", content, None).into_string())
}
#[derive(Deserialize)]
struct SignupTicketForm {
api_key: String,
}
#[post("/signup/ticket")]
async fn signup_ticket_handler(
db: Data<Arc<DB>>,
config: Data<Arc<Server>>,
form: Form<SignupTicketForm>,
) -> impl Responder {
let form = form.into_inner();
if form.api_key != config.admin_api_key() {
return HttpResponse::Found()
.append_header(("Location", "/signup/ticket?error=invalid_key"))
.finish();
}
let ticket = Ticket::new("admin_api");
let code = ticket.code.clone();
if let Err(e) = db.save_ticket(&ticket).await {
error!("Failed to create ticket: {}", e);
return HttpResponse::InternalServerError().body("Failed to create ticket");
}
info!("Ticket generated via signup page: {}", code);
let content = html! {
div class="pa4 flex justify-center" {
div class="mw7 w-100" {
h1 class="f2 fw6 mb4 tc orange" { "Your Invite Code" }
div class="ba b--dark-gray pa4 bg-near-black br2 tc" {
p class="f6 mb2 light-silver" { "Use this code to sign up:" }
code class="f3 fw7 orange" { (code) }
p class="f6 mt3 light-silver" { "This code can only be used once. Keep it safe." }
}
div class="tc mt4" {
a href="/signup" class="f5 link dim br2 ph3 pv2 dib near-black bg-orange" { "Sign Up Now" }
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Invite Code - Corium", content, None).into_string())
}
#[get("/signup")]
async fn signup_page(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user = get_user_from_request(&req, &db).await;
if user.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/dashboard"))
.finish();
}
let content = html! {
div class="pa4 flex justify-center" {
div class="mw7 w-100" {
h1 class="f2 fw6 mb4 tc orange" { "Sign Up" }
form method="post" action="/signup" class="ba b--dark-gray pa4 bg-near-black br2 " {
div class="mb3" {
label class="f6 b db mb2 near-white" for="ticket" { "Invite Code" }
input
class="input-reset ba b--orange pa2 db w-100"
type="text" name="ticket" id="ticket" required placeholder="Enter your invite code" {}
small class="f6 orange db mt1" {
"Need a code? "
a href="/signup/ticket" class="link hot-pink underline" { "Get one with an API key" }
}
}
div class="mb3" {
label class="f6 b db mb2 near-white" for="email" { "Email" }
input
class="input-reset ba b--orange pa2 db w-100"
type="email" name="email" id="email" required placeholder="you@example.com" {}
}
div class="mb3" {
label class="f6 b db mb2 near-white" for="password" { "Password" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="password" id="password" required minlength="8" placeholder="At least 8 characters" {}
}
div class="mb4" {
label class="f6 b db mb2 near-white" for="password_confirm" { "Confirm Password" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="password_confirm" id="password_confirm" required placeholder="Repeat your password" {}
}
button type="submit" class="w-100 f5 link dim br2 ph3 pv3 dib near-black bg-orange pointer" {
"Create Account"
}
}
p class="f6 tc mt3 light-silver" {
"Already have an account? "
a href="/login" class="link hot-pink underline" { "Log in" }
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Sign Up - Corium", content, None).into_string())
}
#[derive(Deserialize)]
struct SignupForm {
email: String,
password: String,
password_confirm: String,
ticket: String,
}
#[post("/signup")]
async fn signup_handler(db: Data<Arc<DB>>, form: Form<SignupForm>) -> impl Responder {
let form = form.into_inner();
if form.password != form.password_confirm {
return HttpResponse::Found()
.append_header(("Location", "/signup?error=password_mismatch"))
.finish();
}
if form.password.len() < 8 {
return HttpResponse::Found()
.append_header(("Location", "/signup?error=password_short"))
.finish();
}
if db.get_user_by_email(&form.email).await.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/signup?error=email_taken"))
.finish();
}
let ticket = match db.get_ticket_by_code(&form.ticket).await {
Some(t) => t,
None => {
return HttpResponse::Found()
.append_header(("Location", "/signup?error=invalid_ticket"))
.finish();
}
};
if ticket.used_by.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/signup?error=ticket_used"))
.finish();
}
let user = match User::new(&form.email, &form.password) {
Ok(u) => u,
Err(e) => {
error!("Failed to create user: {}", e);
return HttpResponse::Found()
.append_header(("Location", "/signup?error=unknown"))
.finish();
}
};
if db.save_user(&user).await.is_err() {
return HttpResponse::InternalServerError().body("Failed to save user");
}
if let Err(e) = db.use_ticket(&form.ticket, &user.id).await {
error!("Failed to mark ticket as used: {}", e);
}
let session = Session::new(&user.id);
let max_age = session.cookie_max_age_secs();
let token = session.token.clone();
if db.save_session(&session).await.is_err() {
return HttpResponse::InternalServerError().body("Failed to create session");
}
info!("User {} signed up", user.email);
HttpResponse::Found()
.cookie(set_session_cookie(&token, max_age))
.append_header(("Location", "/dashboard"))
.finish()
}
#[get("/logout")]
async fn logout_handler(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
if let Some(cookie) = req.cookie(SESSION_COOKIE) {
let token = cookie.value().to_string();
let _ = db.delete_session(&token).await;
}
HttpResponse::Found()
.cookie(clear_session_cookie())
.append_header(("Location", "/"))
.finish()
}
// --- Admin Auth (API Key based) ---
#[get("/admin/login")]
async fn admin_login_page() -> impl Responder {
let content = html! {
div class="pa4 flex justify-center" {
div class="mw7 w-100" {
h1 class="f2 fw6 mb4 tc hot-pink" { "Admin Login" }
form method="post" action="/admin/login" class="ba b--dark-gray pa4 bg-near-black br2 " {
div class="mb4" {
label class="f6 b db mb2 near-white" for="api_key" { "Admin API Key" }
input
class="input-reset ba b--hot-pink pa2 db w-100"
type="password" name="api_key" id="api_key" required placeholder="Enter admin API key" {}
small class="f6 orange db mt1" { "Set via ADMIN_API_KEY environment variable" }
}
button type="submit" class="w-100 f5 link dim br2 ph3 pv3 dib near-black bg-hot-pink pointer" {
"Login as Admin"
}
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Admin Login - Corium", content, None).into_string())
}
#[derive(Deserialize)]
struct AdminLoginForm {
api_key: String,
}
#[post("/admin/login")]
async fn admin_login_handler(config: Data<Arc<Server>>, form: Form<AdminLoginForm>) -> impl Responder {
let form = form.into_inner();
if form.api_key != config.admin_api_key() {
return HttpResponse::Found()
.append_header(("Location", "/admin/login?error=invalid"))
.finish();
}
info!("Admin authenticated via API key");
HttpResponse::Found()
.cookie(set_admin_cookie(config.admin_api_key()))
.append_header(("Location", "/admin/tickets"))
.finish()
}
#[get("/admin/logout")]
async fn admin_logout_handler() -> impl Responder {
HttpResponse::Found()
.cookie(clear_admin_cookie())
.append_header(("Location", "/"))
.finish()
}
// --- Dashboard ---
#[get("/dashboard")]
async fn dashboard(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user = match get_user_from_request(&req, &db).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login"))
.finish();
}
};
let codes = db.list_codes_by_user(&user.id).await;
let endpoint_count = codes.len();
let cards: Vec<Markup> = codes.iter().map(|code| {
let edit_url = format!("/endpoints/edit/{}", code.id());
let run_url = format!("/run{}", code.path());
let delete_url = format!("/api/endpoints{}", code.path());
let target_id = format!("endpoint-{}", code.id());
let lang_str = match code.lang() {
Lang::Kotlin => "Kotlin",
Lang::Rust => "Rust",
};
html! {
div class="fl w-100 w-50-m w-33-l pa2" id=(target_id) {
div class="ba b--dark-gray pa3 bg-black near-white br2 " {
h3 class="f4 fw6 mb2 truncate orange" { (code.path()) }
div class="f6 gray mb2" { "Language: " (lang_str) }
div class="f7 gray mb2" { "Updated: " (format_timestamp(code.updated_at())) }
div class="flex gap2 mt2" {
a href=(edit_url) class="f6 link dim br2 ba bw1 ph2 pv1 dib orange" { "Edit" }
a href=(run_url) class="f6 link dim br2 ba bw1 ph2 pv1 dib hot-pink" { "Run" }
button
hx-delete=(delete_url)
hx-confirm="Are you sure you want to delete this endpoint?"
hx-target={"#" (target_id)}
hx-swap="outerHTML"
class="f6 link dim br2 ba bw1 ph2 pv1 dib dark-red pointer" {
"Delete"
}
}
}
}
}
}).collect();
let content = html! {
div class="pa4 mw8 center" {
div class="flex justify-between items-center mb4" {
div {
h1 class="f2 fw6 mb0 orange" { "Dashboard" }
p class="f6 light-silver" { "Welcome back, " (user.email) }
}
a href="/endpoints/create" class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange" {
"+ New Endpoint"
}
}
div class="flex gap3 mb4" {
div class="ba b--dark-gray pa3 bg-black near-white br2 w-100 w-25-l" {
h3 class="f6 ttu gray mb1" { "Endpoints" }
p class="f2 fw6 orange mb0" { (endpoint_count) }
}
a href="/endpoints/create" class="ba b--dark-gray pa3 bg-black near-white br2 w-100 w-25-l link near-white" {
h3 class="f6 ttu gray mb1" { "Create" }
p class="f2 fw6 hot-pink mb0" { "+" }
}
a href="/endpoints" class="ba b--dark-gray pa3 bg-black near-white br2 w-100 w-25-l link near-white" {
h3 class="f6 ttu gray mb1" { "View All" }
p class="f2 fw6 hot-pink mb0" { (PreEscaped("→")) }
}
a href="/settings" class="ba b--dark-gray pa3 bg-black near-white br2 w-100 w-25-l link near-white" {
h3 class="f6 ttu gray mb1" { "Settings" }
p class="f2 fw6 orange mb0" { (PreEscaped("⚙")) }
}
}
@if cards.is_empty() {
div class="pa5 ba b--dark-gray bg-black near-white br2 tc" {
p class="f4 gray mb3" { "No endpoints yet" }
a href="/endpoints/create" class="f6 link dim br2 ph3 pv2 dib near-black bg-orange" {
"Create your first endpoint"
}
}
} @else {
h2 class="f4 fw6 mb3 orange" { "Your Endpoints" }
div class="cf" {
@for card in &cards {
(card)
}
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Dashboard - Corium", content, Some(&user)).into_string())
}
// --- Endpoint Pages ---
#[get("/endpoints")]
async fn list_endpoints(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user = match get_user_from_request(&req, &db).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login"))
.finish();
}
};
let codes = db.list_codes_by_user(&user.id).await;
let cards: Vec<Markup> = codes.iter().map(|code| {
let edit_url = format!("/endpoints/edit/{}", code.id());
let run_url = format!("/run{}", code.path());
let delete_url = format!("/api/endpoints{}", code.path());
let target_id = format!("endpoint-{}", code.id());
let lang_str = match code.lang() {
Lang::Kotlin => "Kotlin",
Lang::Rust => "Rust",
};
html! {
div class="fl w-100 w-50-m w-33-l pa2" id=(target_id) {
div class="ba b--dark-gray pa3 bg-black near-white br2 " {
h3 class="f4 fw6 mb2 truncate orange" { (code.path()) }
div class="f6 gray mb2" { "Language: " (lang_str) }
div class="flex gap2 mt2" {
a href=(edit_url) class="f6 link dim br2 ba bw1 ph2 pv1 dib orange" { "Edit" }
a href=(run_url) class="f6 link dim br2 ba bw1 ph2 pv1 dib hot-pink" { "Run" }
button
hx-delete=(delete_url)
hx-confirm="Are you sure?"
hx-target={"#" (target_id)}
hx-swap="outerHTML"
class="f6 link dim br2 ba bw1 ph2 pv1 dib dark-red pointer" {
"Delete"
}
}
}
}
}
}).collect();
let content = html! {
div class="pa4 mw8 center" {
div class="flex justify-between items-center mb4" {
h1 class="f2 fw6 orange" { "Endpoints" }
a href="/endpoints/create" class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange" { "+ New Endpoint" }
}
@if cards.is_empty() {
div class="pa4 ba b--dark-gray bg-black near-white" {
p class="f5 gray" { "No endpoints yet. Create your first one!" }
}
} @else {
div class="cf" {
@for card in &cards {
(card)
}
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Endpoints - Corium", content, Some(&user)).into_string())
}
#[get("/endpoints/create")]
async fn create_endpoint_form(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let _user = match get_user_from_request(&req, &db).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login"))
.finish();
}
};
let content = html! {
div class="pa3" {
h1 class="f2 fw6 mb3 orange" { "Create New Endpoint" }
(endpoint_editor(None, DEFAULT_KOTLIN_CODE, "", "/api/endpoints", "post", false, &[], &[]))
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Create Endpoint - Corium", content, Some(&_user)).into_string())
}
#[get("/endpoints/edit/{id}")]
async fn edit_endpoint_form(
req: HttpRequest,
db: Data<Arc<DB>>,
path: Path<String>,
) -> impl Responder {
let user = match get_user_from_request(&req, &db).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login"))
.finish();
}
};
let id = path.into_inner();
match db.load_code_by_id(&id).await {
Some(code) => {
if !code.user_id().is_empty() && code.user_id() != user.id {
return HttpResponse::Forbidden().body("Not authorized");
}
let code_versions = db.list_code_versions(code.id()).await;
let test_versions = db.list_test_versions(code.id()).await;
let form_url = format!("/api/endpoints{}", code.path());
let content = html! {
div class="pa3" {
h1 class="f2 fw6 mb3 orange" { "Edit Endpoint" }
(endpoint_editor(Some(code.path()), code.raw(), code.hurl(), &form_url, "put", true, &code_versions, &test_versions))
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Edit Endpoint - Corium", content, Some(&user)).into_string())
}
None => HttpResponse::NotFound().body("Endpoint not found"),
}
}
fn endpoint_editor(
path: Option<&str>,
code: &str,
hurl: &str,
form_url: &str,
method: &str,
show_actions: bool,
code_versions: &[CodeVersion],
test_versions: &[TestVersion],
) -> Markup {
let escaped_code = html_escape(code);
let escaped_hurl = html_escape(hurl);
let code_versions_json = serde_json::to_string(code_versions).unwrap_or_else(|_| "[]".to_string());
let test_versions_json = serde_json::to_string(test_versions).unwrap_or_else(|_| "[]".to_string());
let path_val = path.unwrap_or("");
html! {
form
id="endpoint-form"
hx-post=(form_url)
hx-ext="response-targets"
hx-target-error="#endpoint-error"
class="w-100"
{
div id="endpoint-error" class="dark-red mb2" {}
div class="flex gap3 mb2" {
div class="flex-auto" {
label class="f6 b db mb2 near-white" for="path" { "Endpoint Path" }
input
class="input-reset ba b--orange pa2 db w-100"
type="text" name="path" id="path" placeholder="/api/hello"
value=(path_val)
required pattern="^/[a-zA-Z0-9_/-]+$"
title="Must start with / and contain only letters, numbers, underscores, hyphens, and forward slashes" {}
}
div class="w5" {
label class="f6 b db mb2 near-white" for="lang" { "Language" }
select class="db w-100 ba b--orange pa2" name="lang" id="lang" {
option value="kotlin" selected { "Kotlin (WASM)" }
option value="rust" { "Rust (WASM - coming soon)" }
}
}
}
div class="flex gap2 mb2" {
button type="button" class="tab-btn f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer" id="tab-code" onclick="switchTab('code')" { "Code" }
button type="button" class="tab-btn f6 link dim br2 ba bw1 ph3 pv2 dib near-white bg-dark-gray pointer" id="tab-test" onclick="switchTab('test')" { "Test" }
}
div id="code-panel" {
div class="mb2" {
label class="f6 b db mb2 near-white" for="code" { "Source Code" }
textarea
class="code-editor input-reset ba b--orange pa2 db w-100"
name="code" id="code" rows="20"
placeholder="Write your code here..."
style="min-height: 50vh; resize: vertical;"
{ (PreEscaped(&escaped_code)) }
}
@if show_actions {
div class="flex gap3 mb2 items-center" {
select id="code-version-select" class="ba b--orange pa2 bg-near-black near-white" style="display:none" {
}
button
type="button"
id="save-version-btn"
class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer"
onclick="saveCodeVersion()" {
"Save Version"
}
button
type="button"
id="deploy-btn"
class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-green pointer"
onclick="deployEndpoint()" {
"Deploy"
}
}
} @else {
button type="submit" class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer" {
"Create Endpoint"
}
}
}
div id="test-panel" style="display:none" {
div class="flex gap3 mb2" {
div class="flex-auto" {
label class="f6 b db mb2 near-white" { "Hurl Test" }
textarea
class="code-editor input-reset ba b--orange pa2 db w-100"
name="hurl" id="hurl" rows="6"
placeholder=(PreEscaped("GET /api/hello?name=World\nor key=value per line"))
{ (PreEscaped(&escaped_hurl)) }
}
div class="flex flex-column gap2 justify-end" {
button
type="button"
id="test-btn"
class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-hot-pink pointer"
onclick="testEndpoint()" {
"Run Test"
}
}
}
div class="mb2" {
label class="f6 b db mb2 near-white" { "Output" }
div id="test-result" class="output-panel ba b--dark-gray br2 pa3 overflow-auto" style="min-height: 30vh;" {
p class="f6 gray" { "Run a test to see results" }
}
}
@if show_actions {
div class="flex gap3 items-center" {
select id="test-version-select" class="ba b--orange pa2 bg-near-black near-white" style="display:none" {
}
button
type="button"
id="save-test-btn"
class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer"
onclick="saveTestVersion()" {
"Save Test"
}
}
}
}
@if method == "put" {
input type="hidden" name="_method" value="PUT" {}
}
}
script {
(PreEscaped(format!(r#"
var codeVersions = {code_versions_json};
var testVersions = {test_versions_json};
var endpointPath = '{path_val}';
function switchTab(tab) {{
var codePanel = document.getElementById('code-panel');
var testPanel = document.getElementById('test-panel');
var tabCode = document.getElementById('tab-code');
var tabTest = document.getElementById('tab-test');
if (tab === 'code') {{
codePanel.style.display = '';
testPanel.style.display = 'none';
tabCode.className = 'tab-btn f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer';
tabTest.className = 'tab-btn f6 link dim br2 ba bw1 ph3 pv2 dib near-white bg-dark-gray pointer';
}} else {{
codePanel.style.display = 'none';
testPanel.style.display = '';
tabCode.className = 'tab-btn f6 link dim br2 ba bw1 ph3 pv2 dib near-white bg-dark-gray pointer';
tabTest.className = 'tab-btn f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer';
}}
}}
function populateVersionSelects() {{
var codeSelect = document.getElementById('code-version-select');
var testSelect = document.getElementById('test-version-select');
if (codeVersions.length > 0) {{
codeSelect.style.display = '';
codeSelect.innerHTML = '<option value="">-- Load version --</option>';
codeVersions.forEach(function(v) {{
var opt = document.createElement('option');
opt.value = v.id;
opt.textContent = 'v' + v.version + ' (' + new Date(v.created_at * 1000).toLocaleString() + ')';
codeSelect.appendChild(opt);
}});
codeSelect.onchange = function() {{
var v = codeVersions.find(function(v) {{ return v.id === codeSelect.value; }});
if (v) {{
document.getElementById('code').value = v.raw;
}}
}};
}}
if (testVersions.length > 0) {{
testSelect.style.display = '';
testSelect.innerHTML = '<option value="">-- Load version --</option>';
testVersions.forEach(function(v) {{
var opt = document.createElement('option');
opt.textContent = 'v' + v.version + ': ' + v.hurl.substring(0, 40) + (v.hurl.length > 40 ? '...' : '');
opt.value = v.id;
testSelect.appendChild(opt);
}});
testSelect.onchange = function() {{
var v = testVersions.find(function(v) {{ return v.id === testSelect.value; }});
if (v) {{
document.getElementById('hurl').value = v.hurl;
}}
}};
}}
}}
populateVersionSelects();
function testEndpoint() {{
var pathEl = document.getElementById('path');
var hurlEl = document.getElementById('hurl');
var resultEl = document.getElementById('test-result');
if (!pathEl || !hurlEl) return;
var path = pathEl.value;
var hurl = hurlEl.value;
var testPath = '/run' + path;
var params = new URLSearchParams();
var lines = hurl.split('\\n');
for (var i = 0; i < lines.length; i++) {{
var line = lines[i].trim();
if (line.indexOf('=') > 0 && line.indexOf('GET') !== 0 && line.indexOf('POST') !== 0) {{
var eqIdx = line.indexOf('=');
var key = line.substring(0, eqIdx).trim();
var val = line.substring(eqIdx + 1).trim();
if (key) params.append(key, val);
}}
}}
if (lines.length > 0) {{
var firstLine = lines[0].trim();
var qIdx = firstLine.indexOf('?');
if (qIdx > 0) {{
var queryString = firstLine.substring(qIdx + 1);
var pairs = queryString.split('&');
for (var j = 0; j < pairs.length; j++) {{
var pair = pairs[j];
var eqPos = pair.indexOf('=');
if (eqPos > 0) {{
params.append(pair.substring(0, eqPos), pair.substring(eqPos + 1));
}}
}}
}}
}}
var url = testPath;
if (params.toString()) {{
url += '?' + params.toString();
}}
resultEl.innerHTML = '<p class="f6 orange">Running test...</p>';
fetch(url)
.then(function(r) {{ return r.json(); }})
.then(function(data) {{
var output = JSON.stringify(data, null, 2);
var status = data.status || '—';
var statusColor = status >= 200 && status < 300 ? 'green' : 'dark-red';
resultEl.innerHTML = '<div class="flex items-center gap2 mb2"><span class="f6 b gray">Status:</span><span class="f6 b ' + statusColor + '">' + status + '</span></div><pre class="f6 code-editor overflow-auto" style="background:transparent;border:none;padding:0;white-space:pre-wrap;">' + output.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
}})
.catch(function(err) {{
resultEl.innerHTML = '<h4 class="f6 b mb2 dark-red">Error</h4><p class="f6 dark-red">' + err.message + '</p>';
}});
}}
function saveCodeVersion() {{
var pathEl = document.getElementById('path');
var codeEl = document.getElementById('code');
var langEl = document.getElementById('lang');
if (!pathEl) return;
var endpointId = '';
var pathParts = window.location.pathname.split('/');
endpointId = pathParts[pathParts.length - 1];
var btn = document.getElementById('save-version-btn');
btn.textContent = 'Saving...';
btn.disabled = true;
var form = document.getElementById('endpoint-form');
var formData = new FormData();
formData.append('path', pathEl.value);
formData.append('lang', langEl ? langEl.value : 'kotlin');
formData.append('code', codeEl ? codeEl.value : '');
formData.append('_method', 'PUT');
fetch(form.action, {{
method: 'POST',
body: formData,
headers: {{ 'X-Requested-With': 'XMLHttpRequest' }}
}})
.then(function(r) {{
if (r.ok) {{
btn.textContent = 'Saved!';
setTimeout(function() {{
btn.textContent = 'Save Version';
btn.disabled = false;
window.location.reload();
}}, 1000);
}} else {{
btn.textContent = 'Save Version';
btn.disabled = false;
return r.text();
}}
}})
.catch(function(err) {{
btn.textContent = 'Save Version';
btn.disabled = false;
}});
}}
function saveTestVersion() {{
var hurlEl = document.getElementById('hurl');
var pathParts = window.location.pathname.split('/');
var endpointId = pathParts[pathParts.length - 1];
var btn = document.getElementById('save-test-btn');
btn.textContent = 'Saving...';
btn.disabled = true;
fetch('/api/endpoints/' + endpointId + '/test-versions', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},
body: 'hurl=' + encodeURIComponent(hurlEl ? hurlEl.value : ''),
}})
.then(function(r) {{
if (r.ok) {{
btn.textContent = 'Saved!';
setTimeout(function() {{
btn.textContent = 'Save Test';
btn.disabled = false;
window.location.reload();
}}, 1000);
}} else {{
btn.textContent = 'Save Test';
btn.disabled = false;
}}
}})
.catch(function(err) {{
btn.textContent = 'Save Test';
btn.disabled = false;
}});
}}
function deployEndpoint() {{
var resultEl = document.getElementById('test-result');
var btn = document.getElementById('deploy-btn');
btn.textContent = 'Deploying...';
setTimeout(function() {{
btn.textContent = 'Deploy';
switchTab('test');
resultEl.innerHTML = '<div class="flex items-center gap2 mb2"><span class="f6 b orange">Deploy</span></div><p class="f6 gray">Deploy functionality coming soon.</p>';
}}, 1000);
}}
"#, code_versions_json = code_versions_json, test_versions_json = test_versions_json, path_val = path_val)))
}
}
}
// --- API Handlers ---
#[derive(Deserialize)]
struct CreateEndpointForm {
path: String,
lang: String,
code: String,
hurl: Option<String>,
}
#[post("/api/endpoints")]
async fn create_endpoint(
req: HttpRequest,
db: Data<Arc<DB>>,
compiler: Data<Arc<Compiler>>,
form: Form<CreateEndpointForm>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => {
return HttpResponse::Unauthorized()
.content_type("text/html")
.body(html! { div class="dark-red" { "Not authenticated" } }.into_string());
}
};
let form = form.into_inner();
if !form.path.starts_with('/') {
return HttpResponse::BadRequest()
.content_type("text/html")
.body(html! { div class="dark-red" { "Path must start with /" } }.into_string());
}
if db.load_code(&form.path).await.is_some() {
return HttpResponse::Conflict()
.content_type("text/html")
.body(html! { div class="dark-red" { "Endpoint with this path already exists" } }.into_string());
}
let lang = match form.lang.as_str() {
"kotlin" => Lang::Kotlin,
"rust" => Lang::Rust,
_ => Lang::Kotlin,
};
match compiler.compile(&form.code, lang.clone()).await {
Ok(wasm_bytes) => {
let mut code = Code::new(&form.path, &form.code, lang.clone(), wasm_bytes.clone());
code.user_id = user_id;
code.hurl = form.hurl.clone().unwrap_or_default();
match db.save_code(&code).await {
Ok(_) => {
let code_version = CodeVersion::new(code.id(), 1, &form.code, lang, wasm_bytes);
let _ = db.save_code_version(&code_version).await;
if let Some(ref hurl) = form.hurl {
if !hurl.is_empty() {
let test_version = TestVersion::new(code.id(), 1, hurl);
let _ = db.save_test_version(&test_version).await;
}
}
info!("Created endpoint at {}", form.path);
let edit_url = format!("/endpoints/edit/{}", code.id());
HttpResponse::Created()
.append_header(("HX-Redirect", edit_url.clone()))
.content_type("text/html")
.body(html! { div { "saved" } }.into_string())
}
Err(e) => {
error!("Failed to save endpoint: {}", e);
HttpResponse::InternalServerError()
.content_type("text/html")
.body(html! { div class="dark-red" { "Failed to save: " (e) } }.into_string())
}
}
}
Err(e) => {
error!("Compilation failed: {}", e);
HttpResponse::BadRequest()
.content_type("text/html")
.body(html! { div class="dark-red" { "Compilation failed: " (e) } }.into_string())
}
}
}
#[derive(Deserialize)]
struct UpdateEndpointForm {
path: String,
lang: String,
code: String,
hurl: Option<String>,
#[serde(rename = "_method")]
_method: Option<String>,
}
#[post("/api/endpoints/{path:.*}")]
async fn update_endpoint(
req: HttpRequest,
db: Data<Arc<DB>>,
compiler: Data<Arc<Compiler>>,
path: Path<String>,
form: Form<UpdateEndpointForm>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => {
return HttpResponse::Unauthorized()
.content_type("text/html")
.body(html! { div class="dark-red" { "Not authenticated" } }.into_string());
}
};
let path = format!("/{}", path.into_inner());
let form = form.into_inner();
if form._method.as_deref() != Some("PUT") {
return HttpResponse::MethodNotAllowed().finish();
}
let mut existing = match db.load_code(&path).await {
Some(code) => code,
None => return HttpResponse::NotFound()
.content_type("text/html")
.body(html! { div class="dark-red" { "Endpoint not found" } }.into_string()),
};
if !existing.user_id().is_empty() && existing.user_id() != user_id {
return HttpResponse::Forbidden()
.content_type("text/html")
.body(html! { div class="dark-red" { "Not authorized" } }.into_string());
}
let lang = match form.lang.as_str() {
"kotlin" => Lang::Kotlin,
"rust" => Lang::Rust,
_ => Lang::Kotlin,
};
match compiler.compile(&form.code, lang.clone()).await {
Ok(wasm_bytes) => {
let version_number = db.next_code_version_number(existing.id()).await;
let code_version = CodeVersion::new(existing.id(), version_number, &form.code, lang.clone(), wasm_bytes.clone());
let _ = db.save_code_version(&code_version).await;
existing.set_path(&form.path);
existing.set_revision(&form.code, lang, wasm_bytes);
existing.hurl = form.hurl.unwrap_or_default();
match db.save_code(&existing).await {
Ok(_) => {
info!("Updated endpoint at {}", form.path);
let edit_url = format!("/endpoints/edit/{}", existing.id());
HttpResponse::Ok()
.append_header(("HX-Redirect", edit_url.clone()))
.content_type("text/html")
.body(html! { div { "saved" } }.into_string())
}
Err(e) => {
error!("Failed to update endpoint: {}", e);
HttpResponse::InternalServerError()
.content_type("text/html")
.body(html! { div class="dark-red" { "Failed to save: " (e) } }.into_string())
}
}
}
Err(e) => {
error!("Compilation failed: {}", e);
HttpResponse::BadRequest()
.content_type("text/html")
.body(html! { div class="dark-red" { "Compilation failed: " (e) } }.into_string())
}
}
}
#[delete("/api/endpoints/{path:.*}")]
async fn delete_endpoint(
req: HttpRequest,
db: Data<Arc<DB>>,
path: Path<String>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized()
.content_type("text/html")
.body(html! { div class="dark-red" { "Not authenticated" } }.into_string()),
};
let path = format!("/{}", path.into_inner());
if let Some(code) = db.load_code(&path).await {
if !code.user_id().is_empty() && code.user_id() != user_id {
return HttpResponse::Forbidden()
.content_type("text/html")
.body(html! { div class="dark-red" { "Not authorized" } }.into_string());
}
}
if db.delete_code(&path).await {
HttpResponse::Ok()
.content_type("text/html")
.body(html! { div { "deleted" } }.into_string())
} else {
HttpResponse::NotFound()
.content_type("text/html")
.body(html! { div { "Endpoint not found" } }.into_string())
}
}
#[derive(Deserialize)]
struct TestEndpointForm {
hurl: String,
}
#[post("/api/endpoints/{id}/test")]
async fn test_endpoint(
req: HttpRequest,
db: Data<Arc<DB>>,
runner: Data<Arc<Runner>>,
path: Path<String>,
form: Form<TestEndpointForm>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let id = path.into_inner();
let code = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if code.user_id() != user_id && !code.user_id().is_empty() {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let hurl_content = form.into_inner().hurl;
let params = match HurlTest::parse(&hurl_content) {
Ok(test) => test.params,
Err(_) => corium::auth::parse_simple_params(&hurl_content),
};
let params_json = serde_json::to_string(¶ms).unwrap_or_default();
match runner.run(&code, ¶ms_json) {
Ok(result) => HttpResponse::Ok().json(serde_json::json!({
"status": result.status,
"output": result.output,
"path": code.path(),
"lang": code.lang().as_str(),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string(),
"path": code.path(),
})),
}
}
#[derive(Deserialize)]
struct SaveCodeVersionForm {
path: String,
lang: String,
code: String,
#[serde(rename = "_method")]
_method: Option<String>,
}
#[post("/api/endpoints/{id}/versions")]
async fn save_code_version(
req: HttpRequest,
db: Data<Arc<DB>>,
compiler: Data<Arc<Compiler>>,
path: Path<String>,
form: Form<SaveCodeVersionForm>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let id = path.into_inner();
let mut existing = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if !existing.user_id().is_empty() && existing.user_id() != user_id {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let form = form.into_inner();
let lang = match form.lang.as_str() {
"kotlin" => Lang::Kotlin,
"rust" => Lang::Rust,
_ => Lang::Kotlin,
};
match compiler.compile(&form.code, lang.clone()).await {
Ok(wasm_bytes) => {
let version_number = db.next_code_version_number(existing.id()).await;
let version = CodeVersion::new(existing.id(), version_number, &form.code, lang.clone(), wasm_bytes.clone());
let _ = db.save_code_version(&version).await;
existing.set_path(&form.path);
existing.set_revision(&form.code, lang, wasm_bytes);
let _ = db.save_code(&existing).await;
info!("Saved code version v{} for endpoint {}", version_number, existing.path());
let edit_url = format!("/endpoints/edit/{}", existing.id());
HttpResponse::Ok()
.append_header(("HX-Redirect", edit_url.clone()))
.content_type("text/html")
.body(html! { div { "saved" } }.into_string())
}
Err(e) => {
error!("Compilation failed: {}", e);
HttpResponse::BadRequest()
.content_type("text/html")
.body(html! { div class="dark-red" { "Compilation failed: " (e) } }.into_string())
}
}
}
#[get("/api/endpoints/{id}/versions")]
async fn list_code_versions(
req: HttpRequest,
db: Data<Arc<DB>>,
path: Path<String>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let id = path.into_inner();
let code = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if !code.user_id().is_empty() && code.user_id() != user_id {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let versions = db.list_code_versions(&id).await;
HttpResponse::Ok().json(versions)
}
#[get("/api/endpoints/{id}/versions/{version_id}")]
async fn get_code_version(
req: HttpRequest,
db: Data<Arc<DB>>,
path: web::Path<(String, String)>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let (id, _version_id) = path.into_inner();
let code = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if !code.user_id().is_empty() && code.user_id() != user_id {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let versions = db.list_code_versions(&id).await;
HttpResponse::Ok().json(versions)
}
#[derive(Deserialize)]
struct SaveTestVersionForm {
hurl: String,
}
#[post("/api/endpoints/{id}/test-versions")]
async fn save_test_version(
req: HttpRequest,
db: Data<Arc<DB>>,
path: Path<String>,
form: Form<SaveTestVersionForm>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let id = path.into_inner();
let mut existing = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if !existing.user_id().is_empty() && existing.user_id() != user_id {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let hurl_content = form.into_inner().hurl;
let version_number = db.next_test_version_number(existing.id()).await;
let version = TestVersion::new(existing.id(), version_number, &hurl_content);
let _ = db.save_test_version(&version).await;
existing.hurl = hurl_content;
let _ = db.save_code(&existing).await;
info!("Saved test version v{} for endpoint {}", version_number, existing.path());
HttpResponse::Ok()
.content_type("text/html")
.body(html! { div { "saved" } }.into_string())
}
#[get("/api/endpoints/{id}/test-versions")]
async fn list_test_versions(
req: HttpRequest,
db: Data<Arc<DB>>,
path: Path<String>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let id = path.into_inner();
let code = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if !code.user_id().is_empty() && code.user_id() != user_id {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let versions = db.list_test_versions(&id).await;
HttpResponse::Ok().json(versions)
}
#[get("/api/endpoints/{id}/test-versions/{version_id}")]
async fn get_test_version(
req: HttpRequest,
db: Data<Arc<DB>>,
path: web::Path<(String, String)>,
) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let (id, _version_id) = path.into_inner();
let code = match db.load_code_by_id(&id).await {
Some(c) => c,
None => return HttpResponse::NotFound().json(serde_json::json!({"error": "Endpoint not found"})),
};
if !code.user_id().is_empty() && code.user_id() != user_id {
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Not authorized"}));
}
let versions = db.list_test_versions(&id).await;
HttpResponse::Ok().json(versions)
}
// --- Settings ---
#[get("/settings")]
async fn settings_page(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user = match get_user_from_request(&req, &db).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login"))
.finish();
}
};
let content = html! {
div class="pa4 mw8 center" {
h1 class="f2 fw6 mb4 orange" { "Account Settings" }
div class="ba b--dark-gray pa4 bg-near-black near-white br2 mb4" {
h2 class="f4 fw6 mb3 orange" { "Profile" }
div class="mb2" {
span class="f6 b gray" { "Email: " }
span class="f6 near-white" { (user.email) }
}
div {
span class="f6 b gray" { "Member since: " }
span class="f6 near-white" { (format_timestamp(user.created_at)) }
}
}
div class="ba b--dark-gray pa4 bg-near-black near-white br2 " {
h2 class="f4 fw6 mb3 hot-pink" { "Change Password" }
form method="post" action="/settings/password" {
div class="mb3" {
label class="f6 b db mb2 near-white" for="current_password" { "Current Password" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="current_password" id="current_password" required {}
}
div class="mb3" {
label class="f6 b db mb2 near-white" for="new_password" { "New Password" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="new_password" id="new_password" required minlength="8" {}
}
div class="mb4" {
label class="f6 b db mb2 near-white" for="confirm_password" { "Confirm New Password" }
input
class="input-reset ba b--orange pa2 db w-100"
type="password" name="confirm_password" id="confirm_password" required {}
}
button type="submit" class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer" {
"Update Password"
}
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Settings - Corium", content, Some(&user)).into_string())
}
#[derive(Deserialize)]
struct ChangePasswordForm {
current_password: String,
new_password: String,
confirm_password: String,
}
#[post("/settings/password")]
async fn update_password(
req: HttpRequest,
db: Data<Arc<DB>>,
form: Form<ChangePasswordForm>,
) -> impl Responder {
let user = match get_user_from_request(&req, &db).await {
Some(u) => u,
None => {
return HttpResponse::Found()
.append_header(("Location", "/login"))
.finish();
}
};
let form = form.into_inner();
if form.new_password != form.confirm_password {
return HttpResponse::Found()
.append_header(("Location", "/settings?error=password_mismatch"))
.finish();
}
if form.new_password.len() < 8 {
return HttpResponse::Found()
.append_header(("Location", "/settings?error=password_short"))
.finish();
}
let mut user = match db.get_user_by_id(&user.id).await {
Some(u) => u,
None => return HttpResponse::NotFound().body("User not found"),
};
if !user.verify_password(&form.current_password) {
return HttpResponse::Found()
.append_header(("Location", "/settings?error=wrong_password"))
.finish();
}
if let Err(e) = user.change_password(&form.new_password) {
error!("Failed to hash password: {}", e);
return HttpResponse::InternalServerError().body("Failed to update password");
}
if let Err(e) = db.update_user_password(&user.id, &user.password_hash).await {
error!("Failed to update password: {}", e);
return HttpResponse::InternalServerError().body("Failed to save password");
}
HttpResponse::Found()
.append_header(("Location", "/settings?success=1"))
.finish()
}
// --- Admin (API Key authenticated) ---
#[get("/admin/tickets")]
async fn admin_tickets_page(req: HttpRequest, db: Data<Arc<DB>>, config: Data<Arc<Server>>) -> impl Responder {
if !validate_admin_api_key(&req, &config) {
return HttpResponse::Found()
.append_header(("Location", "/admin/login"))
.finish();
}
let tickets = db.list_tickets().await;
let ticket_rows: Vec<Markup> = tickets.iter().map(|t| {
let status = if t.used_by.is_some() {
html! { span class="f6 dark-red" { "Used" } }
} else {
html! { span class="f6 orange" { "Available" } }
};
let delete_url = format!("/admin/tickets/{}/delete", t.id);
html! {
tr {
td class="pa2 bb b--dark-gray" { code class="f6 orange" { (t.code) } }
td class="pa2 bb b--dark-gray" { (status) }
td class="pa2 bb b--dark-gray near-white" { (format_timestamp(t.created_at)) }
td class="pa2 bb b--dark-gray" {
form method="post" action=(delete_url) style="display:inline" {
button type="submit" class="f6 link dim br1 ba bw1 ph1 pv1 dib dark-red pointer" {
"Delete"
}
}
}
}
}
}).collect();
let content = html! {
div class="pa4 mw8 center" {
div class="flex justify-between items-center mb4" {
h1 class="f2 fw6 orange" { "Admin: Tickets" }
div class="flex gap3" {
form method="post" action="/admin/tickets" {
button type="submit" class="f6 link dim br2 ba bw1 ph3 pv2 dib near-black bg-orange pointer" {
"Generate Ticket"
}
}
a href="/admin/logout" class="f6 link dim br2 ba bw1 ph3 pv2 dib hot-pink" { "Logout Admin" }
}
}
@if ticket_rows.is_empty() {
div class="pa4 ba b--dark-gray bg-black near-white" {
p class="f5 gray" { "No tickets yet. Generate one to invite new users." }
}
} @else {
table class="w-100 collapse ba b--dark-gray bg-near-black near-white" {
thead {
tr class="bg-black" {
th class="pa2 tl f6 b bb b--dark-gray orange" { "Code" }
th class="pa2 tl f6 b bb b--dark-gray orange" { "Status" }
th class="pa2 tl f6 b bb b--dark-gray orange" { "Created" }
th class="pa2 tl f6 b bb b--dark-gray orange" { "Actions" }
}
}
tbody {
@for row in &ticket_rows {
(row)
}
}
}
}
}
};
HttpResponse::Ok()
.content_type("text/html")
.body(layout("Admin - Tickets", content, None).into_string())
}
#[post("/admin/tickets")]
async fn create_ticket_handler(req: HttpRequest, db: Data<Arc<DB>>, config: Data<Arc<Server>>) -> impl Responder {
if !validate_admin_api_key(&req, &config) {
return HttpResponse::Found()
.append_header(("Location", "/admin/login"))
.finish();
}
let ticket = Ticket::new("admin");
if let Err(e) = db.save_ticket(&ticket).await {
error!("Failed to create ticket: {}", e);
return HttpResponse::InternalServerError().body("Failed to create ticket");
}
info!("Admin created ticket {}", ticket.code);
HttpResponse::Found()
.append_header(("Location", "/admin/tickets"))
.finish()
}
#[post("/admin/tickets/{id}/delete")]
async fn delete_ticket_handler(
req: HttpRequest,
db: Data<Arc<DB>>,
config: Data<Arc<Server>>,
path: Path<String>,
) -> impl Responder {
if !validate_admin_api_key(&req, &config) {
return HttpResponse::Found()
.append_header(("Location", "/admin/login"))
.finish();
}
let id = path.into_inner();
db.delete_ticket(&id).await;
HttpResponse::Found()
.append_header(("Location", "/admin/tickets"))
.finish()
}
// --- Public Endpoints ---
#[get("/wasm/{path:.*}")]
async fn serve_wasm(db: Data<Arc<DB>>, path: Path<String>) -> impl Responder {
let path = format!("/{}", path.into_inner());
match db.load_code(&path).await {
Some(code) => HttpResponse::Ok()
.content_type("application/wasm")
.body(code.wasm().to_vec()),
None => HttpResponse::NotFound().body("WASM endpoint not found"),
}
}
#[get("/run/{path:.*}")]
async fn run_endpoint(
db: Data<Arc<DB>>,
runner: Data<Arc<Runner>>,
path: Path<String>,
query: Query<std::collections::HashMap<String, String>>,
) -> impl Responder {
let endpoint_path = format!("/{}", path.into_inner());
let params = serde_json::to_string(&query.into_inner()).unwrap_or_default();
match db.load_code(&endpoint_path).await {
Some(code) => {
match runner.run(&code, ¶ms) {
Ok(result) => HttpResponse::Ok().json(serde_json::json!({
"status": result.status,
"output": result.output,
"path": endpoint_path,
"lang": code.lang().as_str(),
})),
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string(),
"path": endpoint_path,
})),
}
}
None => HttpResponse::NotFound().json(serde_json::json!({
"error": "Endpoint not found",
"path": endpoint_path,
})),
}
}
#[get("/api/endpoints")]
async fn list_endpoints_api(req: HttpRequest, db: Data<Arc<DB>>) -> impl Responder {
let user_id = match get_user_id_from_request(&req, &db).await {
Some(id) => id,
None => return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authenticated"})),
};
let codes = db.list_codes_by_user(&user_id).await;
HttpResponse::Ok().json(codes)
}
#[get("/health")]
async fn health() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"status": "ok"}))
}
#[get("/up")]
async fn up() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"status": "up"}))
}