arc-web-cli 0.5.0

Create self-contained Arc applications
use actix_session::Session;
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use arc_web::helpers::{csrf, session};
use serde::Deserialize;
use tera::{Context, Tera};

const TEMPLATES: &[(&str, &str)] = &[
    (
        "layouts/public.html",
        include_str!("../resources/views/layouts/public.html"),
    ),
    (
        "layouts/admin.html",
        include_str!("../resources/views/layouts/admin.html"),
    ),
    (
        "components/ui.html",
        include_str!("../resources/views/components/ui.html"),
    ),
    ("home.html", include_str!("../resources/views/home.html")),
    (
        "auth/signin.html",
        include_str!("../resources/views/auth/signin.html"),
    ),
    (
        "auth/register.html",
        include_str!("../resources/views/auth/register.html"),
    ),
    (
        "auth/forgot_password.html",
        include_str!("../resources/views/auth/forgot_password.html"),
    ),
    (
        "auth/reset_password.html",
        include_str!("../resources/views/auth/reset_password.html"),
    ),
    (
        "admin/dashboard.html",
        include_str!("../resources/views/admin/dashboard.html"),
    ),
    (
        "admin/profile.html",
        include_str!("../resources/views/admin/profile.html"),
    ),
    (
        "admin/settings.html",
        include_str!("../resources/views/admin/settings.html"),
    ),
    (
        "errors/403.html",
        include_str!("../resources/views/errors/403.html"),
    ),
    (
        "errors/404.html",
        include_str!("../resources/views/errors/404.html"),
    ),
    (
        "errors/500.html",
        include_str!("../resources/views/errors/500.html"),
    ),
];

fn render(name: &str, mut context: Context, status: actix_web::http::StatusCode) -> HttpResponse {
    context.insert("app_name", env!("CARGO_PKG_NAME"));
    let mut tera = Tera::default();
    if let Err(error) = tera.add_raw_templates(TEMPLATES.iter().copied()) {
        return HttpResponse::InternalServerError().body(format!("template error: {error}"));
    }
    match tera.render(name, &context) {
        Ok(html) => HttpResponse::build(status)
            .content_type("text/html; charset=utf-8")
            .body(html),
        Err(error) => HttpResponse::InternalServerError().body(format!("template error: {error}")),
    }
}

fn page(name: &str) -> HttpResponse {
    render(name, Context::new(), actix_web::http::StatusCode::OK)
}

#[get("/")]
pub async fn root() -> impl Responder {
    HttpResponse::Found()
        .insert_header(("Location", "/home"))
        .finish()
}

#[get("/home")]
pub async fn home() -> impl Responder {
    page("home.html")
}

#[get("/signin")]
async fn signin(session: Session) -> impl Responder {
    let mut context = Context::new();
    context.insert("csrf_token", &csrf::get_csrf_token(&session));
    page_with("auth/signin.html", context)
}

#[derive(Deserialize)]
struct SignInForm {
    email: String,
    password: String,
    csrf_token: String,
}

#[post("/signin")]
async fn signin_submit(form: web::Form<SignInForm>, session_data: Session) -> impl Responder {
    if !csrf::validate_and_regenerate_csrf_token(&session_data, &form.csrf_token) {
        return render(
            "errors/403.html",
            Context::new(),
            actix_web::http::StatusCode::FORBIDDEN,
        );
    }
    if !crate::routes::valid_admin_credentials(&form.email, &form.password) {
        let mut context = Context::new();
        context.insert("csrf_token", &csrf::get_csrf_token(&session_data));
        context.insert("email", &form.email);
        context.insert("error", "Email or password was not recognized.");
        return render(
            "auth/signin.html",
            context,
            actix_web::http::StatusCode::UNAUTHORIZED,
        );
    }
    session::set_session_user(
        &session_data,
        &session::SessionUser {
            id: "generated-admin".into(),
            name: "Administrator".into(),
            email: form.email.clone(),
        },
    );
    HttpResponse::SeeOther()
        .insert_header(("Location", "/admin"))
        .finish()
}

#[post("/signout")]
async fn signout(form: web::Form<SignOutForm>, session_data: Session) -> impl Responder {
    if !csrf::validate_and_regenerate_csrf_token(&session_data, &form.csrf_token) {
        return HttpResponse::Forbidden().finish();
    }
    session::clear_session_user(&session_data);
    HttpResponse::SeeOther()
        .insert_header(("Location", "/home"))
        .finish()
}

#[derive(Deserialize)]
struct SignOutForm {
    csrf_token: String,
}

#[get("/register")]
async fn register(session_data: Session) -> impl Responder {
    if std::env::var("SELF_REGISTRATION").as_deref() != Ok("true") {
        return not_found();
    }
    let mut context = Context::new();
    context.insert("csrf_token", &csrf::get_csrf_token(&session_data));
    page_with("auth/register.html", context)
}

#[get("/forgot-password")]
async fn forgot_password(session_data: Session) -> impl Responder {
    let mut context = Context::new();
    context.insert("csrf_token", &csrf::get_csrf_token(&session_data));
    page_with("auth/forgot_password.html", context)
}

#[get("/reset-password/{token}")]
async fn reset_password(token: web::Path<String>, session_data: Session) -> impl Responder {
    let mut context = Context::new();
    context.insert("csrf_token", &csrf::get_csrf_token(&session_data));
    context.insert("token", token.as_str());
    context.insert("token_valid", &false);
    page_with("auth/reset_password.html", context)
}

#[get("")]
async fn dashboard(session_data: Session) -> impl Responder {
    admin_page("admin/dashboard.html", &session_data)
}
#[get("/profile")]
async fn profile(session_data: Session) -> impl Responder {
    admin_page("admin/profile.html", &session_data)
}
#[get("/settings")]
async fn settings(session_data: Session) -> impl Responder {
    admin_page("admin/settings.html", &session_data)
}

fn admin_page(name: &str, session_data: &Session) -> HttpResponse {
    let mut context = Context::new();
    context.insert("session_csrf_token", &csrf::get_csrf_token(session_data));
    page_with(name, context)
}

fn page_with(name: &str, context: Context) -> HttpResponse {
    render(name, context, actix_web::http::StatusCode::OK)
}

async fn browser_not_found(request: HttpRequest) -> HttpResponse {
    if request.path().starts_with("/api/") {
        HttpResponse::NotFound().json(serde_json::json!({"error": "not found"}))
    } else {
        not_found()
    }
}

fn not_found() -> HttpResponse {
    render(
        "errors/404.html",
        Context::new(),
        actix_web::http::StatusCode::NOT_FOUND,
    )
}

pub fn config(cfg: &mut web::ServiceConfig) {
    use arc_web::http::middlewares::{
        auth_middleware::AuthMiddleware, idle_timeout_middleware::IdleTimeoutMiddleware,
    };
    cfg.service(root)
        .service(home)
        .service(signin)
        .service(signin_submit)
        .service(signout)
        .service(register)
        .service(forgot_password)
        .service(reset_password)
        .service(
            web::scope("/admin")
                .wrap(AuthMiddleware)
                .wrap(IdleTimeoutMiddleware::from_env())
                .service(dashboard)
                .service(profile)
                .service(settings),
        )
        .default_service(web::route().to(browser_not_found));
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn every_generated_template_compiles() {
        let mut tera = Tera::default();
        tera.add_raw_templates(TEMPLATES.iter().copied()).unwrap();
        let mut context = Context::new();
        context.insert("app_name", "fixture");
        context.insert("csrf_token", "fixture-token");
        context.insert("session_csrf_token", "fixture-token");
        for name in [
            "home.html",
            "auth/signin.html",
            "admin/dashboard.html",
            "errors/404.html",
        ] {
            let rendered = tera.render(name, &context);
            assert!(rendered.is_ok(), "{name}: {:?}", rendered.unwrap_err());
        }
    }
}