initial
This commit is contained in:
commit
f4b83d14ec
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
10
.idea/.gitignore
vendored
Normal file
10
.idea/.gitignore
vendored
Normal 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
|
||||||
15
.idea/microtldregistry.iml
Normal file
15
.idea/microtldregistry.iml
Normal 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
8
.idea/modules.xml
Normal 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
6
.idea/vcs.xml
Normal 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
3956
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal 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
7
backend/Cargo.lock
generated
Normal 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
11
backend/Cargo.toml
Normal 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}
|
||||||
291
backend/src/cloudflare/mod.rs
Normal file
291
backend/src/cloudflare/mod.rs
Normal 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
151
backend/src/lib.rs
Normal 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
7
web/Cargo.lock
generated
Normal 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
30
web/Cargo.toml
Normal 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
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
40
web/src/dns.rs
Normal 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
32
web/src/main.rs
Normal 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
|
||||||
|
}
|
||||||
23
web/src/web/content/assets/mod.rs
Normal file
23
web/src/web/content/assets/mod.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
web/src/web/content/mod.rs
Normal file
3
web/src/web/content/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub mod partials;
|
||||||
|
pub mod style;
|
||||||
|
pub mod assets;
|
||||||
24
web/src/web/content/partials/footer.rs
Normal file
24
web/src/web/content/partials/footer.rs
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/web/content/partials/head.rs
Normal file
21
web/src/web/content/partials/head.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
web/src/web/content/partials/header.rs
Normal file
23
web/src/web/content/partials/header.rs
Normal 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";
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
web/src/web/content/partials/mod.rs
Normal file
7
web/src/web/content/partials/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
pub mod head;
|
||||||
|
pub mod header;
|
||||||
|
pub mod footer;
|
||||||
|
pub mod page;
|
||||||
|
|
||||||
21
web/src/web/content/partials/page.rs
Normal file
21
web/src/web/content/partials/page.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
web/src/web/content/style/css/base.css
Normal file
3
web/src/web/content/style/css/base.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
html {
|
||||||
|
background-color: #0e0e0e;
|
||||||
|
}
|
||||||
80
web/src/web/content/style/mod.rs
Normal file
80
web/src/web/content/style/mod.rs
Normal 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
0
web/src/web/htmx/mod.rs
Normal file
3
web/src/web/mod.rs
Normal file
3
web/src/web/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub(crate) mod content;
|
||||||
|
pub(crate) mod htmx;
|
||||||
|
pub(crate) mod service;
|
||||||
32
web/src/web/service/auth/mod.rs
Normal file
32
web/src/web/service/auth/mod.rs
Normal 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 {}
|
||||||
30
web/src/web/service/auth/user.rs
Normal file
30
web/src/web/service/auth/user.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
11
web/src/web/service/extractors/dns.rs
Normal file
11
web/src/web/service/extractors/dns.rs
Normal 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>,
|
||||||
|
}
|
||||||
|
|
||||||
1
web/src/web/service/extractors/mod.rs
Normal file
1
web/src/web/service/extractors/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod dns;
|
||||||
85
web/src/web/service/mod.rs
Normal file
85
web/src/web/service/mod.rs
Normal 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 => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/web/service/routes/auth/login.rs
Normal file
21
web/src/web/service/routes/auth/login.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/web/service/routes/auth/loginprompt.rs
Normal file
21
web/src/web/service/routes/auth/loginprompt.rs
Normal 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" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/web/service/routes/auth/logout.rs
Normal file
21
web/src/web/service/routes/auth/logout.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
web/src/web/service/routes/auth/logoutprompt.rs
Normal file
20
web/src/web/service/routes/auth/logoutprompt.rs
Normal 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" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
web/src/web/service/routes/auth/mod.rs
Normal file
15
web/src/web/service/routes/auth/mod.rs
Normal 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;
|
||||||
23
web/src/web/service/routes/auth/register.rs
Normal file
23
web/src/web/service/routes/auth/register.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
web/src/web/service/routes/auth/registerprompt.rs
Normal file
23
web/src/web/service/routes/auth/registerprompt.rs
Normal 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" {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
web/src/web/service/routes/mod.rs
Normal file
56
web/src/web/service/routes/mod.rs
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
18
web/src/web/service/routes/root.rs
Normal file
18
web/src/web/service/routes/root.rs
Normal 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!"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user