etwin_cli 0.12.5

Command Line Interface for Eternaltwin
Documentation
use crate::cmd::backend::{create_prod_system, create_system, ApiConfig, ClientConfig, ClockConfig, StoreConfig};
use axum::http::header;
use axum::http::{StatusCode, Uri};
use clap::Parser;
use etwin_config::Config;
use etwin_core::core::LocaleId;
use etwin_core::forum::{ForumSectionDisplayName, ForumSectionKey, UpsertSystemSectionOptions};
use etwin_core::oauth::{OauthClientDisplayName, OauthClientKey, UpsertSystemClientOptions};
use etwin_core::password::Password;
use etwin_core::types::AnyError;
use std::net::SocketAddr;
use std::str::FromStr;

/// Arguments to the `start` task.
#[derive(Debug, Parser)]
pub struct Args {}

pub async fn run(_args: &Args) -> Result<(), AnyError> {
  let config: Config = etwin_config::find_config(std::env::current_dir().unwrap()).unwrap();
  let secret = config.etwin.secret.as_str();
  let api = if std::env::var("NODE_ENV").as_deref() == Ok("production") {
    create_prod_system(&config.etwin.external_uri, &config.db, &config.auth, secret).await
  } else if config.etwin.api.as_str() == "postgres" {
    create_system(
      ApiConfig {
        clock: ClockConfig::System,
        store: StoreConfig::Postgres,
        client: ClientConfig::Http,
      },
      &config.etwin.external_uri,
      Some(&config.db),
      Some(&config.auth),
      false,
      secret,
    )
    .await
    .unwrap()
  } else {
    create_system(
      ApiConfig {
        clock: ClockConfig::Virtual,
        store: StoreConfig::Memory,
        client: ClientConfig::Memory,
      },
      &config.etwin.external_uri,
      None,
      Some(&config.auth),
      true,
      secret,
    )
    .await
    .unwrap()
  };

  eprintln!("loaded internal Eternaltwin system");

  for (client_key, client_config) in config.clients {
    api
      .oauth
      .as_ref()
      .upsert_system_client(&UpsertSystemClientOptions {
        key: OauthClientKey::from_str(format!("{client_key}@clients").as_str())
          .unwrap_or_else(|e| panic!("invalid client key {client_key:?}: {e}")),
        display_name: OauthClientDisplayName::from_str(client_config.display_name.as_str())
          .unwrap_or_else(|e| panic!("invalid client display name {:?}: {e}", client_config.display_name)),
        app_uri: client_config.app_uri,
        callback_uri: client_config.callback_uri,
        secret: Password::from(client_config.secret.as_bytes()),
      })
      .await
      .unwrap_or_else(|e| panic!("failed client upsert {client_key:?}: {e}"));
  }

  for (section_key, section_config) in config.forum.sections {
    api
      .forum
      .as_ref()
      .upsert_system_section(&UpsertSystemSectionOptions {
        key: ForumSectionKey::from_str(section_key.as_str())
          .unwrap_or_else(|e| panic!("invalid section key {section_key:?}: {e}")),
        display_name: ForumSectionDisplayName::from_str(section_config.display_name.as_str())
          .unwrap_or_else(|e| panic!("invalid section display name {:?}: {e}", section_config.display_name)),
        locale: section_config
          .locale
          .as_deref()
          .map(LocaleId::from_str)
          .transpose()
          .unwrap_or_else(|e| panic!("invalid locale id {:?}: {e}", section_config.locale)),
      })
      .await
      .unwrap_or_else(|e| panic!("failed section upsert {section_key:?}: {e}"));
  }

  eprintln!("initialization complete");

  let mut listen_addr: SocketAddr = "[::]:80".parse().unwrap();
  listen_addr.set_port(config.etwin.http_port);

  let backend = etwin_rest::app(api).fallback(fallback);

  eprintln!("started server at http://localhost:{}", listen_addr.port());

  axum::Server::bind(&listen_addr)
    .serve(backend.into_make_service())
    .await
    .unwrap();

  Ok(())
}

async fn fallback(uri: Uri) -> (StatusCode, [(header::HeaderName, &'static str); 2], Vec<u8>) {
  const INDEX: &str = "index.html";

  let path = uri.path();
  let path = path.strip_prefix('/').unwrap_or(path);
  let file = if path == INDEX {
    None
  } else {
    etwin_app::BROWSER.get_file(path)
  };
  let (media_type, file) = match file {
    None => {
      let index = match etwin_app::BROWSER.get_file(INDEX) {
        None => {
          eprintln!("<-> GET {} 1ms Not Found", uri.path(),);
          return (
            StatusCode::INTERNAL_SERVER_ERROR,
            [
              (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
              (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
            ],
            "Not Found".to_string().into_bytes(),
          );
        }
        Some(index) => index,
      };
      ("text/html; charset=utf-8", index)
    }
    Some(file) => {
      // See <https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types>
      let extension = match path.rfind('.').map(|idx| &path[idx..]) {
        Some(".css") => "text/css; charset=utf-8",
        Some(".gif") => "image/gif",
        Some(".ico") => "image/vnd.microsoft.icon",
        Some(".jpg") => "image/jpeg",
        Some(".jpeg") => "image/jpeg",
        Some(".js") => "text/javascript; charset=utf-8",
        Some(".png") => "image/png",
        Some(".svg") => "image/svg+xml; charset=utf-8",
        Some(".woff2") => "font/woff2",
        ext => {
          if let Some(ext) = ext {
            eprintln!("unknown file extension: {ext}");
          }
          "application/octet-stream"
        }
      };
      (extension, file)
    }
  };
  eprintln!("<-> GET {} 1ms OK", uri.path(),);
  (
    StatusCode::OK,
    [
      (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
      (header::CONTENT_TYPE, media_type),
    ],
    file.contents().to_vec(),
  )
}