about summary refs log tree commit diff
path: root/corp/rih/backend/src/main.rs
use anyhow::{bail, Context, Result};
use log::{debug, error, info, warn, LevelFilter};
use rouille::{Request, Response};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::env;
use std::net::SocketAddr;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;

mod yandex_log;

/// Represents the request sent by the frontend application.
#[derive(Debug, Deserialize)]
struct FrontendReq {
    captcha_token: String,
    record: Record,
}

/// Represents a single record as filled in by a user. This is the
/// primary data structure we want to populate and persist somewhere.
#[derive(Debug, Deserialize, Serialize)]
struct Record {
    // Record-specific metadata
    uuid: Uuid,

    // Personal information
    name: String,
    email: String,
    citizenship: String, // TODO
    personal_details: String,

    // Job information
    position: String,
    technologies: BTreeSet<String>,
    job_details: String,
    work_background: String,
}

impl Record {
    fn validate(&self) -> bool {
        true
    }
}

fn validate_captcha(token: &str) -> Result<()> {
    // TODO(tazjin): pass `ip` parameter
    let url = "https://smartcaptcha.yandexcloud.net/validate";
    let backend_key =
        env::var("YANDEX_SMARTCAPTCHA_KEY").context("captcha verification key not provided")?;

    #[derive(Deserialize)]
    struct CaptchaResponse {
        status: String,
        message: String,
    }

    let response: CaptchaResponse = attohttpc::get(url)
        .param("secret", backend_key)
        .param("token", token)
        .send()
        .context("failed to send captcha verification request")?
        .error_for_status()
        .context("captcha verification request failed")?
        .json()
        .context("failed to deserialize captcha verification response")?;

    if response.status != "ok" {
        warn!(
            "invalid captcha: {} ({})",
            response.message, response.status
        );
    }

    info!("captcha token was valid");

    Ok(())
}

fn persist_record(ip: &SocketAddr, record: &Record) -> Result<()> {
    let bucket_name = "rih-backend-data";
    let credentials =
        s3::creds::Credentials::from_env().context("failed to initialise storage credentials")?;

    let yandex_region: s3::Region = s3::Region::Custom {
        region: "ru-central1".to_string(),
        endpoint: "storage.yandexcloud.net".to_string(),
    };

    let bucket = s3::Bucket::new(bucket_name, yandex_region, credentials)
        .context("failed to initialise storage client")?;

    let path_uuid = Uuid::new_v4();
    let epoch = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("failed to get current time")?
        .as_secs();

    let path = format!("/records/{}-{}.json", epoch, path_uuid);

    info!("writing record to '{}'", path);

    let data = serde_json::json!({
        "ip": ip.to_string(),
        "record": record,
    });

    let response = bucket
        .put_object(path, data.to_string().as_bytes())
        .context("failed to persist storage object")?;

    debug!(
        "Object Storage response: ({}) {}",
        response.status_code(),
        response.as_str().unwrap_or("<unprintable>")
    );

    Ok(())
}

fn handle_submit(req: &Request) -> Result<Response> {
    let submitted: FrontendReq =
        rouille::input::json::json_input(req).context("failed to deserialise frontend request")?;

    validate_captcha(&submitted.captcha_token)?;

    if !submitted.record.validate() {
        bail!("invalid record: {:?}", submitted.record);
    }

    persist_record(req.remote_addr(), &submitted.record).context("failed to persist record")?;

    Ok(Response::text("success"))
}

fn main() -> Result<()> {
    log::set_logger(&yandex_log::YANDEX_CLOUD_LOGGER)
        .map(|()| log::set_max_level(LevelFilter::Info))
        .expect("log configuration must succeed");
    let port = env::var("PORT").unwrap_or_else(|_| /* rihb = */ "7442".to_string());
    let listen = format!("0.0.0.0:{port}");

    info!("launching rih-backend on: {}", listen);

    rouille::start_server(&listen, move |request| {
        if request.method() == "POST" && request.url() == "/submit" {
            info!("handling submit request from {}", request.remote_addr());
            match handle_submit(request) {
                Ok(response) => {
                    info!("submit handled successfully");
                    response
                }
                Err(err) => {
                    error!("failed to handle submit: {}", err);
                    Response::empty_400()
                }
            }
        } else {
            warn!(
                "no matching route for request: {} {}",
                request.method(),
                request.url()
            );

            Response::empty_404()
        }
    });
}