This commit is contained in:
CanadianBaconBoi 2026-08-19 07:24:40 +02:00
commit f4b83d14ec
41 changed files with 5185 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

10
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/web/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
<excludeFolder url="file://$MODULE_DIR$/target" />
<excludeFolder url="file://$MODULE_DIR$/web/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/microtldregistry.iml" filepath="$PROJECT_DIR$/.idea/microtldregistry.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3956
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[workspace]
resolver = "3"
members = ["web", "backend"]
[workspace.package]
version = "0.1.0"
authors = ["CanadianBacon canadian@cdnbcn.net"]
edition = "2024"
[workspace.dependencies]
microtld-web = {path = "web"}
microtld-backend = {path = "backend"}
tokio = {version = "1.53.1", features = ["full"]}
lmrc-cloudflare = {version = "0.3.16"}
anyhow = {version = "1.0.104"}
serde = {version = "1.0.229", features = ["derive"]}
derive_more = {version = "2.1.1", default-features = false, features = ["display"]}
axum = {version = "0.8.9", features = ["macros"]}
axum-htmx = {version = "0.8.1"}
axum-login = {version = "0.18.0"}
tower-sessions = {version = "0.15.0"}
fred = {version = "10.1.0"}
tracing = {version = "0.1.44"}
reqwest = {version = "0.13.4"}
maud = {version = "0.27.0", features = ["axum"]}
sqlx = {version = "0.9.0", features = ["runtime-tokio", "mysql"] }
async-trait = {version = "0.1.91"}
tracing-subscriber = "0.3.23"
# TODO: DIE :)

7
backend/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "backend"
version = "0.1.0"

11
backend/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "microtld-backend"
version.workspace = true
edition.workspace = true
authors.workspace = true
[dependencies]
tokio = {workspace = true}
lmrc-cloudflare = {workspace = true}
anyhow = {workspace = true}
derive_more = {workspace = true}

View File

@ -0,0 +1,291 @@
use std::str::FromStr;
use std::time::Instant;
use lmrc_cloudflare::{CloudflareClient};
use crate::{ClientNotInitializedError, DnsProvider, DnsRecord, RecordType};
#[derive(Debug)]
pub struct CloudflareDnsProvider {
api_key: String,
zone_id: String,
client: Option<CloudflareClient>
}
impl CloudflareDnsProvider {
pub fn new(api_key: String, zone_id: String) -> anyhow::Result<Self> {
Ok(Self {
client: Some(CloudflareClient::new(&api_key)?), api_key, zone_id
})
}
fn cloudflare_records_to_generic(&self, records: Vec<lmrc_cloudflare::DnsRecord>) -> anyhow::Result<impl IntoIterator<Item=anyhow::Result<CloudflareDnsRecord<'_>>>> {
if records.len() == 0 {
Err(anyhow::anyhow!("DNS record not found"))
} else {
Ok(records.into_iter().map(|rec|
CloudflareDnsRecord::from_record(self, rec)
))
}
}
}
impl<'a> DnsProvider<'a> for CloudflareDnsProvider {
type Client = CloudflareClient;
type Record = CloudflareDnsRecord<'a>;
fn init_client(&mut self) -> anyhow::Result<()> {
if let None = self.client {
self.client = Some(CloudflareClient::new(&self.api_key)?);
}
Ok(())
}
fn get_client(&self) -> Result<&CloudflareClient, ClientNotInitializedError> {
self.client.as_ref().ok_or(ClientNotInitializedError {message: "Client not initialized".to_string()})
}
async fn get_dns_records_by_name(&'a self, name: impl Into<String>) -> anyhow::Result<impl IntoIterator<Item=anyhow::Result<Self::Record>>> {
let client = self.get_client()?;
let dns = client.dns();
let records = dns.list_records(&self.zone_id).name(name.into()).send().await?;
self.cloudflare_records_to_generic(records)
}
async fn get_dns_records_by_ip(&'a self, ip: impl Into<String>) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<Self::Record>>> {
let client = self.get_client()?;
let dns = client.dns();
let records = dns.list_records(&self.zone_id).content(ip.into()).send().await?;
self.cloudflare_records_to_generic(records)
}
async fn get_dns_records(&'a self) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<Self::Record>>> {
let client = self.get_client()?;
let dns = client.dns();
let records = dns.list_records(&self.zone_id).send().await?;
self.cloudflare_records_to_generic(records)
}
async fn get_dns_record_by_id(&'a self, id: &str) -> anyhow::Result<Self::Record> {
let client = self.get_client()?;
let dns = client.dns();
match dns.get_record(&self.zone_id, id).await {
Ok(record) => {
Ok(CloudflareDnsRecord::from_record(self, record)?)
}
Err(e) => {
Err(anyhow::anyhow!("Failed to get DNS record: {}", e))
}
}
}
async fn create_dns_record(&'a self, subdomain: &str, target: &str, record_type: RecordType, ttl: Option<u32>) -> anyhow::Result<Self::Record> {
let client = self.get_client()?;
let dns = client.dns();
match dns.create_record(&self.zone_id)
.name(subdomain)
.content(target)
.record_type(record_type.into())
.ttl(ttl.unwrap_or(1))
.send().await {
Ok(record) => {
Ok(CloudflareDnsRecord::from_record(self, record)?)
}
Err(e) => {
Err(anyhow::anyhow!("Failed to get DNS record: {}", e))
}
}
}
}
pub struct CloudflareDnsRecord<'a> {
provider: &'a CloudflareDnsProvider,
record_id: String,
name: String,
target: String,
record_type: RecordType,
ttl: u32,
priority: Option<u16>,
last_refresh: Instant
}
impl<'a> CloudflareDnsRecord<'a> {
fn from_record(provider: &'a CloudflareDnsProvider, record: lmrc_cloudflare::DnsRecord) -> anyhow::Result<Self>
{
Ok(
CloudflareDnsRecord {
provider,
record_id: record.id,
name: record.name,
target: record.content,
record_type: RecordType::from_str(&record.record_type)?,
ttl: record.ttl,
priority: record.priority,
last_refresh: Instant::now(),
}
)
}
}
impl<'a> DnsRecord<'a> for CloudflareDnsRecord<'a> {
type Client = CloudflareClient;
type Provider = CloudflareDnsProvider;
fn get_provider(&self) -> &Self::Provider {
self.provider
}
async fn name(&mut self) -> anyhow::Result<String> {
if self.last_refresh.elapsed().as_secs() > 300 {
self.refresh().await?;
}
Ok(self.name.clone())
}
async fn target(&mut self) -> anyhow::Result<String> {
if self.last_refresh.elapsed().as_secs() > 300 {
self.refresh().await?;
}
Ok(self.target.clone())
}
async fn ttl(&mut self) -> anyhow::Result<u32> {
if self.last_refresh.elapsed().as_secs() > 300 {
self.refresh().await?;
}
Ok(self.ttl)
}
async fn record_type(&mut self) -> anyhow::Result<RecordType> {
if self.last_refresh.elapsed().as_secs() > 300 {
self.refresh().await?;
}
Ok(self.record_type)
}
async fn is_valid(&self) -> anyhow::Result<bool> {
if self.provider.get_client()?.dns().get_record(&self.provider.zone_id, &self.record_id).await.is_err() {
Ok(false)
} else {
Ok(true)
}
}
async fn priority(&mut self) -> anyhow::Result<Option<u16>> {
if self.last_refresh.elapsed().as_secs() > 300 {
self.refresh().await?;
}
Ok(self.priority)
}
async fn set_target(&mut self, ip: &str) -> anyhow::Result<()> {
self.refresh().await?;
self.provider.get_client()?.dns().update_record(
&self.provider.zone_id,
&self.record_id
).content(ip).send().await?;
self.refresh().await?;
Ok(())
}
async fn set_ttl(&mut self, ttl: u32) -> anyhow::Result<()> {
self.refresh().await?;
self.provider.get_client()?.dns().update_record(
&self.provider.zone_id,
&self.record_id
).ttl(ttl).send().await?;
self.refresh().await?;
Ok(())
}
async fn set_record_type(&mut self, record_type: RecordType) -> anyhow::Result<()> {
self.refresh().await?;
self.provider.get_client()?.dns().update_record(
&self.provider.zone_id,
&self.record_id
).record_type(record_type.into()).send().await?;
self.refresh().await?;
Ok(())
}
async fn set_priority(&mut self, priority: u16) -> anyhow::Result<()> {
self.refresh().await?;
self.provider.get_client()?.dns().update_record(
&self.provider.zone_id,
&self.record_id
).priority(priority).send().await?;
self.refresh().await?;
Ok(())
}
async fn delete(&mut self) -> anyhow::Result<()> {
self.refresh().await?;
self.provider.get_client()?.dns().delete_record(&self.provider.zone_id, &self.record_id).await?;
Ok(())
}
async fn refresh(&mut self) -> anyhow::Result<()> {
let client = self.provider.get_client()?;
let dns = client.dns();
let record = match dns.get_record(&self.provider.zone_id, &self.record_id).await {
Ok(record) => {Ok(record)}
Err(_) => {
match dns.find_record(&self.provider.zone_id, &self.name, self.record_type.into()).await? {
Some(record) => Ok(record),
None => {
match dns.list_records(&self.provider.zone_id).name(&self.name).send().await {
Ok(records) => {
let mut record = None;
for r in records {
if r.name == self.name && r.content == self.target {
record = Some(r);
}
}
if let Some(record) = record {
Ok(record)
} else {
Err(anyhow::anyhow!("DNS record not found"))
}
}
Err(_) => Err(anyhow::anyhow!("Failed to list DNS records"))
}
}
}
}
}?;
self.record_id = record.id;
self.name = record.name;
self.target = record.content;
self.priority = record.priority;
self.ttl = record.ttl;
self.record_type = RecordType::from_str(&record.record_type)?;
self.last_refresh = Instant::now();
Ok(())
}
}
impl Into<lmrc_cloudflare::RecordType> for RecordType {
fn into(self) -> lmrc_cloudflare::RecordType {
match self {
RecordType::A => lmrc_cloudflare::RecordType::A,
RecordType::AAAA => lmrc_cloudflare::RecordType::AAAA,
RecordType::CNAME => lmrc_cloudflare::RecordType::CNAME,
RecordType::MX => lmrc_cloudflare::RecordType::MX,
RecordType::TXT => lmrc_cloudflare::RecordType::TXT,
RecordType::SRV => lmrc_cloudflare::RecordType::SRV,
RecordType::NS => lmrc_cloudflare::RecordType::NS,
RecordType::CAA => lmrc_cloudflare::RecordType::CAA,
RecordType::PTR => lmrc_cloudflare::RecordType::PTR,
RecordType::DNSKEY => lmrc_cloudflare::RecordType::DNSKEY,
RecordType::DS => lmrc_cloudflare::RecordType::DS,
RecordType::HTTPS => lmrc_cloudflare::RecordType::HTTPS,
RecordType::LOC => lmrc_cloudflare::RecordType::LOC,
RecordType::NAPTR => lmrc_cloudflare::RecordType::NAPTR,
RecordType::SMIMEA => lmrc_cloudflare::RecordType::SMIMEA,
RecordType::SSHFP => lmrc_cloudflare::RecordType::SSHFP,
RecordType::SVCB => lmrc_cloudflare::RecordType::SVCB,
RecordType::TLSA => lmrc_cloudflare::RecordType::TLSA,
RecordType::URI => lmrc_cloudflare::RecordType::URI
}
}
}

151
backend/src/lib.rs Normal file
View File

@ -0,0 +1,151 @@
#![allow(async_fn_in_trait)]
use std::fmt::Debug;
use std::str::FromStr;
use std::sync::Arc;
use derive_more::Display;
use tokio::sync::{Mutex, RwLock};
use crate::cloudflare::CloudflareDnsProvider;
pub mod cloudflare;
#[derive(Clone, Debug)]
pub struct MicroTld {
base_url: String,
provider: DnsProviderType,
}
impl MicroTld {
pub fn new(base_url: String, provider: DnsProviderType) -> anyhow::Result<Self> {
Ok(Self {
base_url,
provider,
})
}
pub fn get_base_url(&self) -> &str {
&self.base_url
}
pub fn get_provider_wrapped(&mut self) -> &mut DnsProviderType {
&mut self.provider
}
}
#[derive(Debug, Display, Copy, Clone, PartialEq, Eq, Hash)]
pub enum RecordType {
A,
AAAA,
CNAME,
MX,
TXT,
SRV,
NS,
CAA,
PTR,
DNSKEY,
DS,
HTTPS,
LOC,
NAPTR,
SMIMEA,
SSHFP,
SVCB,
TLSA,
URI,
}
impl FromStr for RecordType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"A" => RecordType::A,
"AAAA" => RecordType::AAAA,
"CNAME" => RecordType::CNAME,
"MX" => RecordType::MX,
"TXT" => RecordType::TXT,
"SRV" => RecordType::SRV,
"NS" => RecordType::NS,
"CAA" => RecordType::CAA,
"PTR" => RecordType::PTR,
"DNSKEY" => RecordType::DNSKEY,
"DS" => RecordType::DS,
"HTTPS" => RecordType::HTTPS,
"LOC" => RecordType::LOC,
"NAPTR" => RecordType::NAPTR,
"SMIMEA" => RecordType::SMIMEA,
"SSHFP" => RecordType::SSHFP,
"SVCB" => RecordType::SVCB,
"TLSA" => RecordType::TLSA,
"URI" => RecordType::URI,
_ => return Err(anyhow::anyhow!("Unsupported record type: {}", s)),
})
}
}
pub trait DnsRecord<'a> where Self: Sized {
type Client;
type Provider: DnsProvider<'a>;
fn get_provider(&self) -> &Self::Provider;
async fn name(&mut self) -> anyhow::Result<String>;
async fn target(&mut self) -> anyhow::Result<String>;
async fn ttl(&mut self) -> anyhow::Result<u32>;
async fn record_type(&mut self) -> anyhow::Result<RecordType>;
async fn is_valid(&self) -> anyhow::Result<bool>;
async fn priority(&mut self) -> anyhow::Result<Option<u16>>;
async fn set_target(&mut self, ip: &str) -> anyhow::Result<()>;
async fn set_ttl(&mut self, ttl: u32) -> anyhow::Result<()>;
async fn set_record_type(&mut self, record_type: RecordType) -> anyhow::Result<()>;
async fn set_priority(&mut self, priority: u16) -> anyhow::Result<()>;
async fn delete(&mut self) -> anyhow::Result<()>;
async fn refresh(&mut self) -> anyhow::Result<()>;
}
pub trait DnsProvider<'a> where Self: Sized {
type Client;
type Record: DnsRecord<'a, Client = Self::Client>;
fn init_client(&mut self) -> anyhow::Result<()>;
fn get_client(&self) -> Result<&Self::Client, ClientNotInitializedError>;
async fn get_dns_records_by_name(&'a self, name: impl Into<String>) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<Self::Record>>>;
async fn get_dns_records_by_ip(&'a self, ip: impl Into<String>) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<Self::Record>>>;
async fn get_dns_records(&'a self) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<Self::Record>>>;
async fn get_dns_record_by_id(&'a self, id: &str) -> anyhow::Result<Self::Record>;
async fn create_dns_record(&'a self, subdomain: &str, target: &str, record_type: RecordType, ttl: Option<u32>) -> anyhow::Result<Self::Record>;
}
#[derive(Clone, Debug)]
pub enum DnsProviderType {
Cloudflare(Arc<RwLock<CloudflareDnsProvider>>),
}
impl DnsProviderType {
pub fn cloudflare(api_key: String, zone_id: String) -> anyhow::Result<Self> {
Ok(Self::Cloudflare(Arc::new(RwLock::new(CloudflareDnsProvider::new(api_key, zone_id)?))))
}
pub fn as_provider(&mut self) -> &mut Arc<RwLock<impl DnsProvider>> {
match self {
Self::Cloudflare(provider) => provider,
}
}
}
#[derive(Debug, Display)]
#[display("Client not initialized: {}", message)]
pub struct ClientNotInitializedError {
message: String,
}
impl std::error::Error for ClientNotInitializedError {}
#[derive(Debug, Display)]
#[display("DNS record does not exist: {}", record_name)]
pub struct RecordDoesNotExistError {
record_name: String,
}
impl std::error::Error for RecordDoesNotExistError {}

7
web/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "web"
version = "0.1.0"

30
web/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "microtld-web"
version.workspace = true
edition.workspace = true
authors.workspace = true
[dependencies]
microtld-backend = {workspace = true}
tokio = {workspace = true}
anyhow = {workspace = true}
axum = {workspace = true }
axum-htmx = {workspace = true }
axum-login = {workspace = true }
tower-sessions = {workspace = true }
fred = {workspace = true }
tracing = {workspace = true }
reqwest = {workspace = true }
maud = {workspace = true }
sqlx = {workspace = true}
tracing-subscriber = {workspace = true, features = ["env-filter"]}
serde = {workspace = true}
async-trait = {workspace = true}
lightningcss = "1.0.0-alpha.72"
inventory = "0.3.24"
tower = "0.5"
tower-http = { version = "0.7.0", features = ["trace", "compression-gzip"] }
rust-embed = "8"
mime_guess = "2"

1
web/assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

40
web/src/dns.rs Normal file
View File

@ -0,0 +1,40 @@
use anyhow::Context;
use microtld_backend::{DnsProvider, DnsProviderType, DnsRecord, MicroTld};
pub fn init_dns_backend<'a>() -> anyhow::Result<MicroTld> {
let base_url = std::env::var("MICROTLD_BASE_URL").context("MICROTLD_BASE_URL")?;
let provider_id = std::env::var("MICROTLD_PROVIDER").context("MICROTLD_PROVIDER")?;
match provider_id.to_ascii_lowercase().as_str() {
"cloudflare" => {
MicroTld::new(
base_url,
DnsProviderType::cloudflare(
std::env::var("MICROTLD_CLOUDFLARE_API_KEY").context("MICROTLD_CLOUDFLARE_API_KEY")?,
std::env::var("MICROTLD_CLOUDFLARE_ZONE_ID").context("MICROTLD_CLOUDFLARE_ZONE_ID")?
)?
)
}
_ => Err(anyhow::anyhow!("Unsupported provider: {}", std::env::var("MICROTLD_PROVIDER")?))?,
}
}
pub async fn get_dns_records_by_name<'a>(provider: &'a impl DnsProvider<'a>, name: impl Into<String>) -> anyhow::Result<impl IntoIterator<Item=anyhow::Result<impl DnsRecord<'a>>>> {
provider.get_dns_records_by_name(name).await
}
pub async fn get_dns_records_by_ip<'a>(provider: &'a impl DnsProvider<'a>, ip: impl Into<String>) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<impl DnsRecord<'a>>>> {
provider.get_dns_records_by_ip(ip).await
}
pub async fn get_dns_records<'a>(provider: &'a impl DnsProvider<'a>) -> anyhow::Result<impl IntoIterator<Item = anyhow::Result<impl DnsRecord<'a>>>> {
provider.get_dns_records().await
}
pub async fn get_dns_record_by_id<'a>(provider: &'a impl DnsProvider<'a>, id: &str) -> anyhow::Result<impl DnsRecord<'a>> {
provider.get_dns_record_by_id(id).await
}
pub async fn create_dns_record<'a>(provider: &'a impl DnsProvider<'a>, subdomain: &str, target: &str, record_type: microtld_backend::RecordType, ttl: Option<u32>) -> anyhow::Result<impl DnsRecord<'a>> {
provider.create_dns_record(subdomain, target, record_type, ttl).await
}

32
web/src/main.rs Normal file
View File

@ -0,0 +1,32 @@
#![feature(str_as_str)]
#![feature(associated_type_defaults)]
mod dns;
mod web;
use tokio::signal;
use microtld_backend::DnsProvider;
use crate::web::service::WebService;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut backend = dns::init_dns_backend()?;
let provider = backend.get_provider_wrapped().as_provider();
{ //Scope for client initialization
let mut provider = provider.write().await;
provider.init_client()?;
}
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let listen_url = std::env::var("LISTEN_URL").expect("LISTEN_URL must be set");
let web_service = WebService::new(
db_url,
listen_url.clone(),
backend,
).await?;
println!("Listening on {}", listen_url);
web_service.run().await
}

View File

@ -0,0 +1,23 @@
use axum::extract::Path;
use axum::http::header;
use axum::response::IntoResponse;
use reqwest::StatusCode;
#[derive(rust_embed::Embed)]
#[folder = "assets/"]
struct Assets;
pub async fn static_handler(Path(path): Path<String>) -> impl IntoResponse {
match Assets::get(&path) {
Some(file) => {
let mime = mime_guess::from_path(&path).first_or_octet_stream();
(
[(header::CONTENT_TYPE, mime.as_ref())],
file.data.to_vec()
).into_response()
},
None => {
StatusCode::NOT_FOUND.into_response()
}
}
}

View File

@ -0,0 +1,3 @@
pub mod partials;
pub mod style;
pub mod assets;

View File

@ -0,0 +1,24 @@
use super::*;
pub fn footer() -> Markup {
html! {
footer {
div class="accessibility" {
a href="#main-content" { "Skip to main content" }
a href="#footer" { "Skip to footer" }
a href="#navigation" { "Skip to navigation" }
}
div class="decoration" {
img src="/static/img/banner-begay.gif";
img src="/static/img/banner-docrime.gif";
img src="/static/img/banner-hate.gif";
}
div class="notice" {
span {
"Cookies are used to store your session."
"We do not use them to track you."
}
}
}
}
}

View File

@ -0,0 +1,21 @@
use maud::DOCTYPE;
use crate::web::content::style;
use super::*;
pub fn head(page_title: &str, page_slug: &str) -> Markup {
let css = style::stylesheet();
html! {
head {
meta charset="utf-8";
title { (page_title) }
link rel="stylesheet" href=(css.route);
link rel="icon" href="/static/favicon.ico";
script src="/assets/htmx.min.js" defer {}
meta name="viewport" content="width=device-width, initial-scale=1";
} // TODO: Drnk monter
}
}

View File

@ -0,0 +1,23 @@
use super::*;
pub fn header(is_auth: bool) -> Markup {
html! {
nav class="navigation" {
ul {
li { a href="/" { "Home" } }
li { a href="/regdomain" { "Register a subdomain" }}
li { a href="/status" { "Status" }}
div class="spacer" {}
@if is_auth {
li { a href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#popup" hx-swap="innerHTML" { "Logout" }}
} @else {
li { a href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#popup" hx-swap="innerHTML" { "Login" }}
li { a href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#popup" hx-swap="innerHTML" { "Register" }}
}
li { a href="https://github.com/microtld/microtld" {
img src="/static/img/github-mark.svg";
}}
}
}
}
}

View File

@ -0,0 +1,7 @@
use maud::{html, Markup};
pub mod head;
pub mod header;
pub mod footer;
pub mod page;

View File

@ -0,0 +1,21 @@
use maud::DOCTYPE;
use crate::web::content::partials::footer::footer;
use crate::web::content::partials::head::head;
use crate::web::content::partials::header::header;
use super::*;
pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
(head(page_title, page_slug))
(header(is_auth))
body {
div class="main-content" {
(content)
}
}
(footer())
}
}
}

View File

@ -0,0 +1,3 @@
html {
background-color: #0e0e0e;
}

View File

@ -0,0 +1,80 @@
// src/styles.rs
use lightningcss::stylesheet::{StyleSheet, ParserOptions, MinifyOptions};
use lightningcss::printer::PrinterOptions;
use lightningcss::targets::{Targets, Browsers};
use std::sync::LazyLock;
pub struct CssFragment(pub &'static str);
inventory::collect!(CssFragment);
static BASE_CSS: &str = include_str!("./css/base.css");
pub struct ProcessedCss {
pub body: String,
pub filename: String,
pub route: String,
}
static STYLESHEET: LazyLock<ProcessedCss> = LazyLock::new(|| build_stylesheet());
pub fn stylesheet() -> &'static ProcessedCss {
&STYLESHEET
}
fn build_stylesheet() -> ProcessedCss {
// Concatenate base CSS and all component fragments
let mut raw = String::from(BASE_CSS);
for fragment in inventory::iter::<CssFragment> {
raw.push('\n');
raw.push_str(fragment.0);
}
// Process with lightningcss
let targets = Targets::from(Browsers {
chrome: Some(95 << 16),
firefox: Some(90 << 16),
safari: Some(15 << 16),
..Browsers::default()
});
let mut sheet = StyleSheet::parse(&raw, ParserOptions {
filename: "styles.css".to_string(),
..ParserOptions::default()
})
.expect("CSS parse error");
sheet
.minify(MinifyOptions {
targets,
..MinifyOptions::default()
})
.expect("CSS minify error");
let result = sheet
.to_css(PrinterOptions {
minify: true,
targets,
..PrinterOptions::default()
})
.expect("CSS print error");
// Hash the output for cache-busting
let hash = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
result.code.hash(&mut hasher);
format!("{:x}", hasher.finish())
};
let filename = format!("style.{hash}.css");
let route = format!("/assets/{filename}");
ProcessedCss {
body: result.code,
filename,
route,
}
}

0
web/src/web/htmx/mod.rs Normal file
View File

3
web/src/web/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub(crate) mod content;
pub(crate) mod htmx;
pub(crate) mod service;

View File

@ -0,0 +1,32 @@
use std::fmt::{Display, Formatter};
use axum_login::{AuthnBackend, UserId};
use crate::web::service::WebService;
mod user;
impl AuthnBackend for WebService {
type User = user::User;
type Credentials = user::Credentials;
type Error = AuthError;
async fn authenticate(&self, creds: Self::Credentials) -> Result<Option<Self::User>, Self::Error> {
todo!()
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
todo!()
}
}
#[derive(Debug)]
pub struct AuthError {
username: String,
reason: String,
}
impl Display for AuthError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "AuthError for user {}: {}", self.username, self.reason)
}
}
impl std::error::Error for AuthError {}

View File

@ -0,0 +1,30 @@
use axum_login::AuthUser;
use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: u64,
pub username: String,
pub email: String,
pub password_hash: String,
pub two_factor_enabled: bool,
pub two_factor_secret: Option<String>,
}
impl AuthUser for User {
type Id = u64;
fn id(&self) -> u64 {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}

View File

@ -0,0 +1,11 @@
use serde::Deserialize;
#[derive(Deserialize)]
struct DnsRecordRequest {
name: Option<String>,
target: Option<String>,
ttl: Option<u32>,
record_type: Option<String>,
priority: Option<u16>,
}

View File

@ -0,0 +1 @@
pub mod dns;

View File

@ -0,0 +1,85 @@
pub mod auth;
pub mod routes;
pub mod extractors;
use sqlx::Pool;
use tokio::signal;
use tracing_subscriber::EnvFilter;
use microtld_backend::MicroTld;
#[derive(Clone)]
pub struct WebService {
state: AppState,
router: axum::Router,
}
#[derive(Debug, Clone)]
pub struct AppState {
db: Pool<sqlx::mysql::MySql>,
config: AppConfig,
micro_tld: MicroTld
}
#[derive(Debug, Clone)]
pub struct AppConfig {
database_url: String,
listen_url: String,
}
impl WebService {
pub async fn new(database_url: String, listen_url: String, micro_tld: MicroTld) -> anyhow::Result<WebService> {
let config = AppConfig {
database_url,
listen_url,
};
let state = AppState {
db: Pool::connect(&config.database_url).await?,
config, micro_tld,
};
let router = routes::apply_routes(axum::Router::new()).with_state(state.clone());
Ok(WebService {router, state})
}
pub async fn run(self) -> anyhow::Result<()> {
// initialize tracing
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let listener = tokio::net::TcpListener::bind(
&self.state.config.listen_url
).await?;
tracing::info!("Listening on {}", self.state.config.listen_url);
axum::serve(listener, self.router)
.with_graceful_shutdown(shutdown_server())
.await?;
Ok(())
}
}
pub async fn shutdown_server() {
let ctlc = async {
signal::ctrl_c()
.await
.expect("Failed to install CTRL-C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctlc => {}
_ = terminate => {}
}
}

View File

@ -0,0 +1,21 @@
use axum::extract::{Form, State};
use axum::response::IntoResponse;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct Login {
username: String,
password: String,
}
impl Route<'_, Form<Login>> for Login {
const PATH: &'static str = "/login";
async fn get_content(state: State<AppState>, Form(login_data): Form<Login>) -> impl IntoResponse {
todo!("implement login");
StatusCode::OK
}
}

View File

@ -0,0 +1,21 @@
use axum::extract::State;
use axum::response::IntoResponse;
use maud::html;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
pub(crate) struct LoginPrompt;
impl Route<'_, ()> for LoginPrompt {
const PATH: &'static str = "/loginprompt";
async fn get_content(_state: State<AppState>, _query: ()) -> impl IntoResponse {
html! {
form {
input type="text" name="username" placeholder="Username" {}
input type="password" name="password" placeholder="" {}
input type="submit" value="Login" {}
}
}
}
}

View File

@ -0,0 +1,21 @@
use axum::extract::{Form, State};
use axum::response::IntoResponse;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct Logout {
username: String,
session_id: String
}
impl Route<'_, Form<Logout>> for Logout {
const PATH: &'static str = "/logout";
async fn get_content(state: State<AppState>, Form(query): Form<Logout>) -> impl IntoResponse {
todo!("implement logout");
StatusCode::OK
}
}

View File

@ -0,0 +1,20 @@
use axum::extract::State;
use axum::response::IntoResponse;
use maud::html;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
pub(crate) struct LogoutPrompt;
impl Route<'_, ()> for LogoutPrompt {
const PATH: &'static str = "/logoutprompt";
async fn get_content(_state: State<AppState>, _query: ()) -> impl IntoResponse {
html! {
p { "Are you sure you want to logout?" }
form {
input type="submit" value="Logout" {}
}
}
}
}

View File

@ -0,0 +1,15 @@
pub mod loginprompt;
pub mod registerprompt;
pub mod logoutprompt;
pub mod login;
pub mod register;
pub mod logout;
pub(crate) const PATH: &'static str = "/auth";
pub(crate) use loginprompt::LoginPrompt;
pub(crate) use registerprompt::RegisterPrompt;
pub(crate) use logoutprompt::LogoutPrompt;
pub(crate) use login::Login;
pub(crate) use register::Register;
pub(crate) use logout::Logout;

View File

@ -0,0 +1,23 @@
use axum::extract::{Form, State};
use axum::response::IntoResponse;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct Register {
username: String,
email: String,
password: String,
password_confirm: String,
}
impl Route<'_, Form<Register>> for Register {
const PATH: &'static str = "/register";
async fn get_content(state: State<AppState>, Form(query): Form<Register>) -> impl IntoResponse {
todo!("implement register");
StatusCode::OK
}
}

View File

@ -0,0 +1,23 @@
use axum::extract::State;
use axum::response::IntoResponse;
use maud::html;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
pub(crate) struct RegisterPrompt;
impl Route<'_, ()> for RegisterPrompt {
const PATH: &'static str = "/registerprompt";
async fn get_content(_state: State<AppState>, _query: ()) -> impl IntoResponse {
html! {
form {
input type="text" name="username" placeholder="Username" {}
input type="email" name="email" placeholder="Email" {}
input type="password" name="password" placeholder="" {}
input type="password" name="password_confirm" placeholder="Confirm password" {}
input type="submit" value="Register" {}
}
}
}
}

View File

@ -0,0 +1,56 @@
mod root;
mod auth;
use axum::extract::State;
use axum::http::header;
use axum::response::IntoResponse;
use axum::Router;
use axum::routing::{get, post};
use tower_http::compression::CompressionLayer;
use tower_http::trace::TraceLayer;
use crate::web::content::assets::static_handler;
use crate::web::content::style;
use crate::web::service::{
AppState
};
pub(crate) trait Route<'a, AD> {
const PATH: &'static str;
const NAME: &'static str = "MicroTLD";
async fn get_content(state: State<AppState>, query: AD) -> impl IntoResponse {
"You should fill out get_content for this route."
}
}
fn auth_routes() -> Router<AppState> {
Router::new()
.route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::get_content))
.route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::get_content))
.route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::get_content))
.route(auth::Login::PATH, post(auth::Login::get_content))
.route(auth::Logout::PATH, post(auth::Logout::get_content))
.route(auth::Register::PATH, post(auth::Register::get_content))
}
pub fn apply_routes(router: axum::Router<AppState>) -> Router<AppState> {
let css = style::stylesheet();
router.route(root::Root::PATH, get(root::Root::get_content))
.route(&css.route, get(css_handler))
.nest(auth::PATH, auth_routes())
.route("/assets/{*path}", get(static_handler))
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
}
async fn css_handler() -> impl IntoResponse {
let css = style::stylesheet();
(
[
(header::CONTENT_TYPE, "text/css"),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
css.body.clone(),
)
}

View File

@ -0,0 +1,18 @@
use axum::extract::State;
use axum::response::IntoResponse;
use maud::html;
use crate::web::content::partials::page::page;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
pub(crate) struct Root;
impl Route<'_, ()> for Root {
const PATH: &'static str = "/";
async fn get_content(State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
page(Self::NAME, Self::PATH, false, html! {
"Hello, world!"
})
}
}