show(), hide(), visible. font-light, normal, medium, semibold, bold, extrabold, black. protocol connection pooling. fetch() with GURT. DNS from HTTP to GURT.
This commit is contained in:
402
dns/src/gurt_server/auth_routes.rs
Normal file
402
dns/src/gurt_server/auth_routes.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
use super::{models::*, AppState};
|
||||
use crate::auth::*;
|
||||
use gurt::prelude::*;
|
||||
use gurt::GurtStatusCode;
|
||||
use sqlx::Row;
|
||||
use chrono::Utc;
|
||||
|
||||
pub(crate) async fn register(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
|
||||
let user: RegisterRequest = serde_json::from_slice(ctx.body())
|
||||
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
|
||||
|
||||
let registrations = 3; // New users get 3 registrations by default
|
||||
|
||||
// Hash password
|
||||
let password_hash = match hash_password(&user.password) {
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Failed to hash password",
|
||||
error: "HASH_ERROR".into(),
|
||||
})?);
|
||||
}
|
||||
};
|
||||
|
||||
// Create user
|
||||
let user_result = sqlx::query(
|
||||
"INSERT INTO users (username, password_hash, registrations_remaining, domain_invite_codes) VALUES ($1, $2, $3, $4) RETURNING id"
|
||||
)
|
||||
.bind(&user.username)
|
||||
.bind(&password_hash)
|
||||
.bind(registrations)
|
||||
.bind(3) // Default 3 domain invite codes
|
||||
.fetch_one(&app_state.db)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(row) => {
|
||||
let user_id: i32 = row.get("id");
|
||||
|
||||
// Generate JWT
|
||||
match generate_jwt(user_id, &user.username, &app_state.jwt_secret) {
|
||||
Ok(token) => {
|
||||
let response = LoginResponse {
|
||||
token,
|
||||
user: UserInfo {
|
||||
id: user_id,
|
||||
username: user.username.clone(),
|
||||
registrations_remaining: registrations,
|
||||
domain_invite_codes: 3,
|
||||
created_at: Utc::now(),
|
||||
},
|
||||
};
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Failed to generate token",
|
||||
error: "JWT_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("duplicate key") {
|
||||
Ok(GurtResponse::bad_request().with_json_body(&Error {
|
||||
msg: "Username already exists",
|
||||
error: "DUPLICATE_USERNAME".into(),
|
||||
})?)
|
||||
} else {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Failed to create user",
|
||||
error: e.to_string(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn login(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
|
||||
let body_bytes = ctx.body();
|
||||
|
||||
let login_req: LoginRequest = serde_json::from_slice(body_bytes)
|
||||
.map_err(|e| {
|
||||
log::error!("JSON parse error: {}", e);
|
||||
GurtError::invalid_message("Invalid JSON")
|
||||
})?;
|
||||
|
||||
// Find user
|
||||
let user_result = sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE username = $1"
|
||||
)
|
||||
.bind(&login_req.username)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(user)) => {
|
||||
// Verify password
|
||||
match verify_password(&login_req.password, &user.password_hash) {
|
||||
Ok(true) => {
|
||||
// Generate JWT
|
||||
match generate_jwt(user.id, &user.username, &app_state.jwt_secret) {
|
||||
Ok(token) => {
|
||||
let response = LoginResponse {
|
||||
token,
|
||||
user: UserInfo {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
registrations_remaining: user.registrations_remaining,
|
||||
domain_invite_codes: user.domain_invite_codes,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
};
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Failed to generate token",
|
||||
error: "JWT_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
Ok(GurtResponse::new(GurtStatusCode::Unauthorized).with_json_body(&Error {
|
||||
msg: "Invalid credentials",
|
||||
error: "INVALID_CREDENTIALS".into(),
|
||||
})?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Password verification failed",
|
||||
error: "PASSWORD_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(GurtResponse::new(GurtStatusCode::Unauthorized).with_json_body(&Error {
|
||||
msg: "Invalid credentials",
|
||||
error: "INVALID_CREDENTIALS".into(),
|
||||
})?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Database error",
|
||||
error: "DATABASE_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_user_info(_ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
let user_result = sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(claims.user_id)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await;
|
||||
|
||||
match user_result {
|
||||
Ok(Some(user)) => {
|
||||
let user_info = UserInfo {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
registrations_remaining: user.registrations_remaining,
|
||||
domain_invite_codes: user.domain_invite_codes,
|
||||
created_at: user.created_at,
|
||||
};
|
||||
Ok(GurtResponse::ok().with_json_body(&user_info)?)
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(GurtResponse::not_found().with_json_body(&Error {
|
||||
msg: "User not found",
|
||||
error: "USER_NOT_FOUND".into(),
|
||||
})?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Database error",
|
||||
error: "DATABASE_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_invite(_ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
// Generate random invite code
|
||||
let invite_code: String = {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..12)
|
||||
.map(|_| {
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
chars[rng.gen_range(0..chars.len())] as char
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Insert invite code into database
|
||||
let insert_result = sqlx::query(
|
||||
"INSERT INTO invite_codes (code, created_by, created_at) VALUES ($1, $2, $3)"
|
||||
)
|
||||
.bind(&invite_code)
|
||||
.bind(claims.user_id)
|
||||
.bind(Utc::now())
|
||||
.execute(&app_state.db)
|
||||
.await;
|
||||
|
||||
match insert_result {
|
||||
Ok(_) => {
|
||||
let response = serde_json::json!({
|
||||
"invite_code": invite_code
|
||||
});
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Failed to create invite",
|
||||
error: "DATABASE_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn redeem_invite(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
let request: serde_json::Value = serde_json::from_slice(ctx.body())
|
||||
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
|
||||
|
||||
let invite_code = request["invite_code"].as_str()
|
||||
.ok_or(GurtError::invalid_message("Missing invite_code"))?;
|
||||
|
||||
// Check if invite code exists and is not used
|
||||
let invite_result = sqlx::query_as::<_, InviteCode>(
|
||||
"SELECT id, code, created_by, used_by, created_at, used_at FROM invite_codes WHERE code = $1 AND used_by IS NULL"
|
||||
)
|
||||
.bind(invite_code)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await;
|
||||
|
||||
match invite_result {
|
||||
Ok(Some(invite)) => {
|
||||
// Mark invite as used and give user 3 additional registrations
|
||||
let mut tx = app_state.db.begin().await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
sqlx::query("UPDATE invite_codes SET used_by = $1, used_at = $2 WHERE id = $3")
|
||||
.bind(claims.user_id)
|
||||
.bind(Utc::now())
|
||||
.bind(invite.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
sqlx::query("UPDATE users SET registrations_remaining = registrations_remaining + 3 WHERE id = $1")
|
||||
.bind(claims.user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
let response = serde_json::json!({
|
||||
"registrations_added": 3
|
||||
});
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(GurtResponse::bad_request().with_json_body(&Error {
|
||||
msg: "Invalid or already used invite code",
|
||||
error: "INVALID_INVITE".into(),
|
||||
})?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Database error",
|
||||
error: "DATABASE_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_domain_invite(_ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
// Check if user has domain invite codes remaining
|
||||
let user: (i32,) = sqlx::query_as("SELECT domain_invite_codes FROM users WHERE id = $1")
|
||||
.bind(claims.user_id)
|
||||
.fetch_one(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("User not found"))?;
|
||||
|
||||
if user.0 <= 0 {
|
||||
return Ok(GurtResponse::bad_request().with_json_body(&Error {
|
||||
msg: "No domain invite codes remaining",
|
||||
error: "NO_INVITES_REMAINING".into(),
|
||||
})?);
|
||||
}
|
||||
|
||||
// Generate random domain invite code
|
||||
let invite_code: String = {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..12)
|
||||
.map(|_| {
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
chars[rng.gen_range(0..chars.len())] as char
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Insert domain invite code and decrease user's count
|
||||
let mut tx = app_state.db.begin().await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO domain_invite_codes (code, created_by, created_at) VALUES ($1, $2, $3)"
|
||||
)
|
||||
.bind(&invite_code)
|
||||
.bind(claims.user_id)
|
||||
.bind(Utc::now())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
sqlx::query("UPDATE users SET domain_invite_codes = domain_invite_codes - 1 WHERE id = $1")
|
||||
.bind(claims.user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
let response = serde_json::json!({
|
||||
"domain_invite_code": invite_code
|
||||
});
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn redeem_domain_invite(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
let request: serde_json::Value = serde_json::from_slice(ctx.body())
|
||||
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
|
||||
|
||||
let invite_code = request["domain_invite_code"].as_str()
|
||||
.ok_or(GurtError::invalid_message("Missing domain_invite_code"))?;
|
||||
|
||||
// Check if domain invite code exists and is not used
|
||||
let invite_result = sqlx::query_as::<_, DomainInviteCode>(
|
||||
"SELECT id, code, created_by, used_by, created_at, used_at FROM domain_invite_codes WHERE code = $1 AND used_by IS NULL"
|
||||
)
|
||||
.bind(invite_code)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await;
|
||||
|
||||
match invite_result {
|
||||
Ok(Some(invite)) => {
|
||||
// Mark invite as used and give user 1 additional domain invite code
|
||||
let mut tx = app_state.db.begin().await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
sqlx::query("UPDATE domain_invite_codes SET used_by = $1, used_at = $2 WHERE id = $3")
|
||||
.bind(claims.user_id)
|
||||
.bind(Utc::now())
|
||||
.bind(invite.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
sqlx::query("UPDATE users SET domain_invite_codes = domain_invite_codes + 1 WHERE id = $1")
|
||||
.bind(claims.user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
let response = serde_json::json!({
|
||||
"domain_invite_codes_added": 1
|
||||
});
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(GurtResponse::bad_request().with_json_body(&Error {
|
||||
msg: "Invalid or already used domain invite code",
|
||||
error: "INVALID_DOMAIN_INVITE".into(),
|
||||
})?)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
|
||||
msg: "Database error",
|
||||
error: "DATABASE_ERROR".into(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Error {
|
||||
msg: &'static str,
|
||||
error: String,
|
||||
}
|
||||
20
dns/src/gurt_server/helpers.rs
Normal file
20
dns/src/gurt_server/helpers.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use gurt::prelude::*;
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub fn validate_ip(domain: &super::models::Domain) -> Result<()> {
|
||||
if domain.ip.parse::<IpAddr>().is_err() {
|
||||
return Err(GurtError::invalid_message("Invalid IP address"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deserialize_lowercase<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(s.to_lowercase())
|
||||
}
|
||||
103
dns/src/gurt_server/models.rs
Normal file
103
dns/src/gurt_server/models.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use super::helpers::deserialize_lowercase;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, types::chrono::{DateTime, Utc}};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct Domain {
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) id: Option<i32>,
|
||||
pub(crate) ip: String,
|
||||
#[serde(deserialize_with = "deserialize_lowercase")]
|
||||
pub(crate) tld: String,
|
||||
#[serde(deserialize_with = "deserialize_lowercase")]
|
||||
pub(crate) name: String,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) user_id: Option<i32>,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) status: Option<String>,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) denial_reason: Option<String>,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) created_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct DnsRecord {
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) id: Option<i32>,
|
||||
pub(crate) domain_id: i32,
|
||||
#[serde(deserialize_with = "deserialize_lowercase")]
|
||||
pub(crate) record_type: String, // A, AAAA, CNAME, TXT, MX, NS
|
||||
#[serde(deserialize_with = "deserialize_lowercase")]
|
||||
pub(crate) name: String, // subdomain or @ for root
|
||||
pub(crate) value: String, // IP, domain, text value, etc.
|
||||
pub(crate) ttl: Option<i32>, // Time to live in seconds
|
||||
pub(crate) priority: Option<i32>, // For MX records
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) created_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct User {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) username: String,
|
||||
pub(crate) password_hash: String,
|
||||
pub(crate) registrations_remaining: i32,
|
||||
pub(crate) domain_invite_codes: i32,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct InviteCode {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) code: String,
|
||||
pub(crate) created_by: Option<i32>,
|
||||
pub(crate) used_by: Option<i32>,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
pub(crate) used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct DomainInviteCode {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) code: String,
|
||||
pub(crate) created_by: Option<i32>,
|
||||
pub(crate) used_by: Option<i32>,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
pub(crate) used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseDomain {
|
||||
pub(crate) tld: String,
|
||||
pub(crate) ip: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) records: Option<Vec<ResponseDnsRecord>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseDnsRecord {
|
||||
pub(crate) record_type: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) value: String,
|
||||
pub(crate) ttl: Option<i32>,
|
||||
pub(crate) priority: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct UpdateDomain {
|
||||
pub(crate) ip: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct PaginationResponse {
|
||||
pub(crate) domains: Vec<ResponseDomain>,
|
||||
pub(crate) page: u32,
|
||||
pub(crate) limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct DomainList {
|
||||
pub(crate) domain: String,
|
||||
pub(crate) taken: bool,
|
||||
}
|
||||
314
dns/src/gurt_server/routes.rs
Normal file
314
dns/src/gurt_server/routes.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
use super::{models::*, AppState, helpers::validate_ip};
|
||||
use crate::auth::Claims;
|
||||
use gurt::prelude::*;
|
||||
use std::{env, collections::HashMap};
|
||||
|
||||
fn parse_query_string(query: &str) -> HashMap<String, String> {
|
||||
let mut params = HashMap::new();
|
||||
for pair in query.split('&') {
|
||||
if let Some((key, value)) = pair.split_once('=') {
|
||||
params.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
pub(crate) async fn index(_app_state: AppState) -> Result<GurtResponse> {
|
||||
let body = format!(
|
||||
"GurtDNS v{}!\n\nThe available endpoints are:\n\n - [GET] /domains\n - [GET] /domain/{{name}}/{{tld}}\n - [POST] /domain\n - [PUT] /domain/{{key}}\n - [DELETE] /domain/{{key}}\n - [GET] /tlds\n\nRatelimits are as follows: 5 requests per 10 minutes on `[POST] /domain`.\n\nCode link: https://github.com/outpoot/gurted",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
Ok(GurtResponse::ok().with_string_body(body))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -> Result<Domain> {
|
||||
validate_ip(&domain)?;
|
||||
|
||||
if !app.config.tld_list().contains(&domain.tld.as_str())
|
||||
|| !domain.name.chars().all(|c| c.is_alphabetic() || c == '-')
|
||||
|| domain.name.len() > 24
|
||||
|| domain.name.is_empty()
|
||||
|| domain.name.starts_with('-')
|
||||
|| domain.name.ends_with('-') {
|
||||
return Err(GurtError::invalid_message("Invalid name, non-existent TLD, or name too long (24 chars)."));
|
||||
}
|
||||
|
||||
if app.config.offen_words().iter().any(|word| domain.name.contains(word)) {
|
||||
return Err(GurtError::invalid_message("The given domain name is offensive."));
|
||||
}
|
||||
|
||||
let existing_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
|
||||
)
|
||||
.bind(&domain.name)
|
||||
.bind(&domain.tld)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
if existing_count > 0 {
|
||||
return Err(GurtError::invalid_message("Domain already exists"));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO domains (name, tld, ip, user_id, status) VALUES ($1, $2, $3, $4, 'pending')"
|
||||
)
|
||||
.bind(&domain.name)
|
||||
.bind(&domain.tld)
|
||||
.bind(&domain.ip)
|
||||
.bind(user_id)
|
||||
.execute(&app.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Failed to create domain"))?;
|
||||
|
||||
// Decrease user's registrations remaining
|
||||
sqlx::query("UPDATE users SET registrations_remaining = registrations_remaining - 1 WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&app.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Failed to update user registrations"))?;
|
||||
|
||||
Ok(domain)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
// Check if user has registrations remaining
|
||||
let user: (i32,) = sqlx::query_as("SELECT registrations_remaining FROM users WHERE id = $1")
|
||||
.bind(claims.user_id)
|
||||
.fetch_one(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("User not found"))?;
|
||||
|
||||
if user.0 <= 0 {
|
||||
return Ok(GurtResponse::bad_request().with_json_body(&Error {
|
||||
msg: "Failed to create domain",
|
||||
error: "No registrations remaining".into(),
|
||||
})?);
|
||||
}
|
||||
|
||||
let domain: Domain = serde_json::from_slice(ctx.body())
|
||||
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
|
||||
|
||||
match create_logic(domain.clone(), claims.user_id, &app_state).await {
|
||||
Ok(created_domain) => {
|
||||
Ok(GurtResponse::ok().with_json_body(&created_domain)?)
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(GurtResponse::bad_request().with_json_body(&Error {
|
||||
msg: "Failed to create domain",
|
||||
error: e.to_string(),
|
||||
})?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_domain(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
|
||||
let path_parts: Vec<&str> = ctx.path().split('/').collect();
|
||||
if path_parts.len() < 4 {
|
||||
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
|
||||
}
|
||||
|
||||
let name = path_parts[2];
|
||||
let tld = path_parts[3];
|
||||
|
||||
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
|
||||
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(tld)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
match domain {
|
||||
Some(domain) => {
|
||||
let response_domain = ResponseDomain {
|
||||
name: domain.name,
|
||||
tld: domain.tld,
|
||||
ip: domain.ip,
|
||||
records: None, // TODO: Implement DNS records
|
||||
};
|
||||
Ok(GurtResponse::ok().with_json_body(&response_domain)?)
|
||||
}
|
||||
None => Ok(GurtResponse::not_found().with_string_body("Domain not found"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_domains(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
|
||||
// Parse pagination from query parameters
|
||||
let path = ctx.path();
|
||||
let query_params = if let Some(query_start) = path.find('?') {
|
||||
let query_string = &path[query_start + 1..];
|
||||
parse_query_string(query_string)
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let page = query_params.get("page")
|
||||
.and_then(|p| p.parse::<u32>().ok())
|
||||
.unwrap_or(1)
|
||||
.max(1); // Ensure page is at least 1
|
||||
|
||||
let page_size = query_params.get("limit")
|
||||
.and_then(|l| l.parse::<u32>().ok())
|
||||
.unwrap_or(100)
|
||||
.clamp(1, 1000); // Limit between 1 and 1000
|
||||
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let domains: Vec<Domain> = sqlx::query_as::<_, Domain>(
|
||||
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE status = 'approved' ORDER BY created_at DESC LIMIT $1 OFFSET $2"
|
||||
)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
let response_domains: Vec<ResponseDomain> = domains.into_iter().map(|domain| {
|
||||
ResponseDomain {
|
||||
name: domain.name,
|
||||
tld: domain.tld,
|
||||
ip: domain.ip,
|
||||
records: None,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let response = PaginationResponse {
|
||||
domains: response_domains,
|
||||
page,
|
||||
limit: page_size,
|
||||
};
|
||||
|
||||
Ok(GurtResponse::ok().with_json_body(&response)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_tlds(app_state: AppState) -> Result<GurtResponse> {
|
||||
Ok(GurtResponse::ok().with_json_body(&app_state.config.tld_list())?)
|
||||
}
|
||||
|
||||
pub(crate) async fn check_domain(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
|
||||
let path = ctx.path();
|
||||
let query_params = if let Some(query_start) = path.find('?') {
|
||||
let query_string = &path[query_start + 1..];
|
||||
parse_query_string(query_string)
|
||||
} else {
|
||||
return Ok(GurtResponse::bad_request().with_string_body("Missing query parameters. Expected ?name=<name>&tld=<tld>"));
|
||||
};
|
||||
|
||||
let name = query_params.get("name")
|
||||
.ok_or_else(|| GurtError::invalid_message("Missing 'name' parameter"))?;
|
||||
let tld = query_params.get("tld")
|
||||
.ok_or_else(|| GurtError::invalid_message("Missing 'tld' parameter"))?;
|
||||
|
||||
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
|
||||
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(tld)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
let domain_list = DomainList {
|
||||
domain: format!("{}.{}", name, tld),
|
||||
taken: domain.is_some(),
|
||||
};
|
||||
|
||||
Ok(GurtResponse::ok().with_json_body(&domain_list)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
let path_parts: Vec<&str> = ctx.path().split('/').collect();
|
||||
if path_parts.len() < 4 {
|
||||
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
|
||||
}
|
||||
|
||||
let name = path_parts[2];
|
||||
let tld = path_parts[3];
|
||||
|
||||
let update_data: UpdateDomain = serde_json::from_slice(ctx.body())
|
||||
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
|
||||
|
||||
// Verify user owns this domain
|
||||
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
|
||||
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(tld)
|
||||
.bind(claims.user_id)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
let domain = match domain {
|
||||
Some(d) => d,
|
||||
None => return Ok(GurtResponse::not_found().with_string_body("Domain not found or access denied"))
|
||||
};
|
||||
|
||||
// Validate IP
|
||||
validate_ip(&Domain {
|
||||
id: domain.id,
|
||||
name: domain.name.clone(),
|
||||
tld: domain.tld.clone(),
|
||||
ip: update_data.ip.clone(),
|
||||
user_id: domain.user_id,
|
||||
status: domain.status,
|
||||
denial_reason: domain.denial_reason,
|
||||
created_at: domain.created_at,
|
||||
})?;
|
||||
|
||||
sqlx::query("UPDATE domains SET ip = $1 WHERE name = $2 AND tld = $3 AND user_id = $4")
|
||||
.bind(&update_data.ip)
|
||||
.bind(name)
|
||||
.bind(tld)
|
||||
.bind(claims.user_id)
|
||||
.execute(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Failed to update domain"))?;
|
||||
|
||||
Ok(GurtResponse::ok().with_string_body("Domain updated successfully"))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
|
||||
let path_parts: Vec<&str> = ctx.path().split('/').collect();
|
||||
if path_parts.len() < 4 {
|
||||
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
|
||||
}
|
||||
|
||||
let name = path_parts[2];
|
||||
let tld = path_parts[3];
|
||||
|
||||
// Verify user owns this domain
|
||||
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
|
||||
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(tld)
|
||||
.bind(claims.user_id)
|
||||
.fetch_optional(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Database error"))?;
|
||||
|
||||
if domain.is_none() {
|
||||
return Ok(GurtResponse::not_found().with_string_body("Domain not found or access denied"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3")
|
||||
.bind(name)
|
||||
.bind(tld)
|
||||
.bind(claims.user_id)
|
||||
.execute(&app_state.db)
|
||||
.await
|
||||
.map_err(|_| GurtError::invalid_message("Failed to delete domain"))?;
|
||||
|
||||
Ok(GurtResponse::ok().with_string_body("Domain deleted successfully"))
|
||||
}
|
||||
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Error {
|
||||
msg: &'static str,
|
||||
error: String,
|
||||
}
|
||||
Reference in New Issue
Block a user