Invite System
-
Create invite codes to share with friends, or redeem codes to get more domain registrations.
-
-
-
-
Create Invite
-
-
-
-
Redeem Invite
-
-
-
-
-
+
Create invite codes to share with friends, or redeem codes to get more domain
+ registrations.
+
+
Placeholder
+
+
+
Create Invite
+
+
+
+
Redeem Invite
+
+
+
+
My Domains
- Loading domains...
-
-
-
-
-
-
-
-
-
Invite Code Generated
-
Share this code with friends to give them 3 additional domain registrations:
-
- Loading...
-
-
-
-
+
Loading domains...
diff --git a/dns/frontend/dashboard.lua b/dns/frontend/dashboard.lua
index 197ff38..b491fd2 100644
--- a/dns/frontend/dashboard.lua
+++ b/dns/frontend/dashboard.lua
@@ -5,19 +5,11 @@ local authToken = nil
local userInfo = gurt.select('#user-info')
local domainsList = gurt.select('#domains-list')
-local logArea = gurt.select('#log-area')
-local inviteModal = gurt.select('#invite-modal')
local tldSelector = gurt.select('#tld-selector')
+local loadingElement = gurt.select('#tld-loading')
+local displayElement = gurt.select('#invite-code-display')
-local logMessages = {}
-
-local function addLog(message)
- table.insert(logMessages, Time.format(Time.now(), '%H:%M:%S') .. ' - ' .. message)
- if #logMessages > 50 then
- table.remove(logMessages, 1)
- end
- logArea.text = table.concat(logMessages, '\n')
-end
+displayElement:hide()
local function showError(elementId, message)
local element = gurt.select('#' .. elementId)
@@ -32,189 +24,51 @@ local function hideError(elementId)
element.classList:add('hidden')
end
-local function showModal(modalId)
- local modal = gurt.select('#' .. modalId)
-
- modal.classList:remove('hidden')
-end
-
-local function hideModal(modalId)
- local modal = gurt.select('#' .. modalId)
-
- modal.classList:add('hidden')
-end
-
-local function makeRequest(url, options)
- options = options or {}
- if authToken then
- options.headers = options.headers or {}
- options.headers.Authorization = 'Bearer ' .. authToken
- end
- return fetch(url, options)
-end
-
-local function checkAuth()
- authToken = gurt.crumbs.get("auth_token")
-
- if authToken then
- addLog('Found auth token, checking validity...')
- local response = makeRequest('gurt://localhost:4878/auth/me')
- print(table.tostring(response))
- if response:ok() then
- user = response:json()
- addLog('Authentication successful for user: ' .. user.username)
- updateUserInfo()
- loadDomains()
- loadTLDs()
- else
- addLog('Token invalid, redirecting to login...')
- --gurt.crumbs.delete('auth_token')
- --gurt.location.goto('../')
- end
- else
- addLog('No auth token found, redirecting to login...')
- gurt.location.goto('../')
- end
-end
-
-local function logout()
- gurt.crumbs.delete('auth_token')
- addLog('Logged out successfully')
- gurt.location.goto("../")
-end
-
-local function loadDomains()
- addLog('Loading domains...')
- local response = makeRequest('gurt://localhost:4878/domains?page=1&size=100')
-
- if response:ok() then
- local data = response:json()
- domains = data.domains or {}
- addLog('Loaded ' .. #domains .. ' domains')
- renderDomains()
- else
- addLog('Failed to load domains: ' .. response:text())
- end
-end
-
-local function loadTLDs()
- addLog('Loading available TLDs...')
- local response = fetch('gurt://localhost:4878/tlds')
-
- if response:ok() then
- tlds = response:json()
- addLog('Loaded ' .. #tlds .. ' TLDs')
- renderTLDSelector()
- else
- addLog('Failed to load TLDs: ' .. response:text())
- end
-end
-
-local function submitDomain(name, tld, ip)
- hideError('domain-error')
- addLog('Submitting domain: ' .. name .. '.' .. tld)
-
- local response = makeRequest('gurt://localhost:4878/domain', {
- method = 'POST',
- headers = { ['Content-Type'] = 'application/json' },
- body = JSON.stringify({ name = name, tld = tld, ip = ip })
- })
-
- if response:ok() then
- local data = response:json()
- addLog('Domain submitted successfully: ' .. data.domain)
-
- -- Update user registrations remaining
- user.registrations_remaining = user.registrations_remaining - 1
- updateUserInfo()
-
- -- Clear form
- gurt.select('#domain-name').text = ''
- gurt.select('#domain-ip').text = ''
-
- -- Refresh domains list
- loadDomains()
- else
- local error = response:text()
- showError('domain-error', 'Domain submission failed: ' .. error)
- addLog('Domain submission failed: ' .. error)
- end
-end
-
-local function createInvite()
- addLog('Creating invite code...')
- local response = makeRequest('gurt://localhost:4878/auth/invite', { method = 'POST' })
-
- if response:ok() then
- local data = response:json()
- local inviteCode = data.invite_code
- gurt.select('#invite-code-display').text = inviteCode
- addLog('Invite code created: ' .. inviteCode)
- showModal('invite-modal')
- else
- addLog('Failed to create invite: ' .. response:text())
- end
-end
-
-local function redeemInvite(code)
- hideError('redeem-error')
- addLog('Redeeming invite code: ' .. code)
-
- local response = makeRequest('gurt://localhost:4878/auth/redeem-invite', {
- method = 'POST',
- headers = { ['Content-Type'] = 'application/json' },
- body = JSON.stringify({ invite_code = code })
- })
-
- if response:ok() then
- local data = response:json()
- addLog('Invite redeemed: +' .. data.registrations_added .. ' registrations')
-
- -- Update user info
- user.registrations_remaining = user.registrations_remaining + data.registrations_added
- updateUserInfo()
-
- -- Clear form
- gurt.select('#invite-code-input').text = ''
- else
- local error = response:text()
- showError('redeem-error', 'Failed to redeem invite: ' .. error)
- addLog('Failed to redeem invite: ' .. error)
- end
-end
-
--- UI rendering functions
local function updateUserInfo()
- if user then
- userInfo.text = 'Welcome, ' .. user.username .. ' | Registrations remaining: ' .. user.registrations_remaining
- end
+ userInfo.text = 'Welcome, ' .. user.username .. '!'
end
local function renderTLDSelector()
+ loadingElement:remove()
+
tldSelector.text = ''
- for i, tld in ipairs(tlds) do
- local option = gurt.create('div', {
+ local i = 1
+ local total = #tlds
+ local intervalId
+
+ intervalId = gurt.setInterval(function()
+ if i > total then
+ gurt.clearInterval(intervalId)
+ return
+ end
+
+ local tld = tlds[i]
+ local option = gurt.create('button', {
text = '.' .. tld,
style = 'tld-option',
['data-tld'] = tld
})
-
+
option:on('click', function()
-- Clear previous selection
local options = gurt.selectAll('.tld-option')
for j = 1, #options do
options[j].classList:remove('tld-selected')
end
-
+
-- Select this option
option.classList:add('tld-selected')
end)
-
+
tldSelector:append(option)
- end
+ i = i + 1
+ end, 16)
end
local function renderDomains()
+ local loadingElement = gurt.select('#domains-loading')
+ loadingElement:remove()
+
domainsList.text = ''
if #domains == 0 then
@@ -281,35 +135,153 @@ local function renderDomains()
end
end
-local function updateDomainIP(name, tld, ip)
- addLog('Updating IP for ' .. name .. '.' .. tld .. ' to ' .. ip)
-
- local response = makeRequest('gurt://localhost:4878/domain/' .. name .. '/' .. tld, {
- method = 'PUT',
- headers = { ['Content-Type'] = 'application/json' },
- body = JSON.stringify({ ip = ip })
+local function loadDomains()
+ print('Loading domains...')
+ local response = fetch('gurt://localhost:8877/domains?page=1&size=100', {
+ headers = {
+ Authorization = 'Bearer ' .. authToken
+ }
})
if response:ok() then
- addLog('Domain IP updated successfully')
- loadDomains()
+ local data = response:json()
+ domains = data.domains or {}
+ print('Loaded ' .. #domains .. ' domains')
+ renderDomains()
else
- addLog('Failed to update domain IP: ' .. response:text())
+ print('Failed to load domains: ' .. response:text())
end
end
-local function deleteDomain(name, tld)
- addLog('Deleting domain: ' .. name .. '.' .. tld)
+local function loadTLDs()
+ print('Loading available TLDs...')
+ local response = fetch('gurt://localhost:8877/tlds')
- local response = makeRequest('gurt://localhost:4878/domain/' .. name .. '/' .. tld, {
- method = 'DELETE'
+ if response:ok() then
+ tlds = response:json()
+ print('Loaded ' .. #tlds .. ' TLDs')
+ renderTLDSelector()
+ else
+ print('Failed to load TLDs: ' .. response:text())
+ end
+end
+
+local function checkAuth()
+ authToken = gurt.crumbs.get("auth_token")
+
+ if authToken then
+ print('Found auth token, checking validity...')
+ local response = fetch('gurt://localhost:8877/auth/me', {
+ headers = {
+ Authorization = 'Bearer ' .. authToken
+ }
+ })
+ print(table.tostring(response))
+ if response:ok() then
+ user = response:json()
+ print('Authentication successful for user: ' .. user.username)
+ updateUserInfo()
+ loadDomains()
+ loadTLDs()
+ else
+ print('Token invalid, redirecting to login...')
+ gurt.crumbs.delete('auth_token')
+ gurt.location.goto('../')
+ end
+ else
+ print('No auth token found, redirecting to login...')
+ gurt.location.goto('../')
+ end
+end
+
+local function logout()
+ gurt.crumbs.delete('auth_token')
+ print('Logged out successfully')
+ gurt.location.goto("../")
+end
+
+local function submitDomain(name, tld, ip)
+ hideError('domain-error')
+ print('Submitting domain: ' .. name .. '.' .. tld)
+
+ local response = fetch('gurt://localhost:8877/domain', {
+ method = 'POST',
+ headers = {
+ ['Content-Type'] = 'application/json',
+ Authorization = 'Bearer ' .. authToken
+ },
+ body = JSON.stringify({ name = name, tld = tld, ip = ip })
})
if response:ok() then
- addLog('Domain deleted successfully')
+ local data = response:json()
+ print('Domain submitted successfully: ' .. data.domain)
+
+ -- Update user registrations remaining
+ user.registrations_remaining = user.registrations_remaining - 1
+ updateUserInfo()
+
+ -- Clear form
+ gurt.select('#domain-name').text = ''
+ gurt.select('#domain-ip').text = ''
+
+ -- Refresh domains list
loadDomains()
else
- addLog('Failed to delete domain: ' .. response:text())
+ local error = response:text()
+ showError('domain-error', 'Domain submission failed: ' .. error)
+ print('Domain submission failed: ' .. error)
+ end
+end
+
+local function createInvite()
+ print('Creating invite code...')
+ local response = fetch('gurt://localhost:8877/auth/invite', {
+ method = 'POST',
+ headers = {
+ Authorization = 'Bearer ' .. authToken
+ }
+ })
+
+ if response:ok() then
+ local data = response:json()
+ local inviteCode = data.invite_code
+ displayElement.text = 'Invite code: ' .. inviteCode .. ' (copied to clipboard)'
+ displayElement:show()
+ Clipboard.write(inviteCode)
+ print('Invite code created and copied to clipboard: ' .. inviteCode)
+ else
+ print('Failed to create invite: ' .. response:text())
+ end
+end
+
+local function redeemInvite(code)
+ hideError('redeem-error')
+ print('Redeeming invite code: ' .. code)
+
+ local response = fetch('gurt://localhost:8877/auth/redeem-invite', {
+ method = 'POST',
+ headers = {
+ ['Content-Type'] = 'application/json',
+ Authorization = 'Bearer ' .. authToken
+ },
+ body = JSON.stringify({ invite_code = code })
+ })
+
+ if response:ok() then
+ local data = response:json()
+ print('Invite redeemed: +' .. data.registrations_added .. ' registrations')
+
+ -- Update user info
+ user.registrations_remaining = user.registrations_remaining + data.registrations_added
+ updateUserInfo()
+
+ -- Clear form
+ gurt.select('#invite-code-input').text = ''
+ else
+ local error = response:text()
+ showError('redeem-error', 'Failed to redeem invite: ' .. error)
+ print('Failed to redeem invite: ' .. error)
end
end
@@ -349,16 +321,6 @@ gurt.select('#redeem-invite-btn'):on('click', function()
end
end)
-gurt.select('#close-invite-modal'):on('click', function()
- hideModal('invite-modal')
-end)
-
-gurt.select('#copy-invite-code'):on('click', function()
- local inviteCode = gurt.select('#invite-code-display').text
- Clipboard.write(inviteCode)
- addLog('Invite code copied to clipboard')
-end)
-
-- Initialize
-addLog('Dashboard initialized')
+print('Dashboard initialized')
checkAuth()
\ No newline at end of file
diff --git a/dns/frontend/script.lua b/dns/frontend/script.lua
index e799fd9..896f664 100644
--- a/dns/frontend/script.lua
+++ b/dns/frontend/script.lua
@@ -1,3 +1,7 @@
+if gurt.crumbs.get("auth_token") then
+ gurt.location.goto("/dashboard.html")
+end
+
local submitBtn = gurt.select('#submit')
local username_input = gurt.select('#username')
local password_input = gurt.select('#password')
@@ -8,7 +12,6 @@ function addLog(message)
log_output.text = log_output.text .. message .. '\\n'
end
-print(gurt.location.href)
submitBtn:on('submit', function(event)
local username = event.data.username
local password = event.data.password
@@ -18,7 +21,7 @@ submitBtn:on('submit', function(event)
password = password
})
print(request_body)
- local url = 'gurt://localhost:8080/auth/login'
+ local url = 'gurt://localhost:8877/auth/login'
local headers = {
['Content-Type'] = 'application/json'
}
diff --git a/dns/src/auth.rs b/dns/src/auth.rs
index 3f41cb4..4f267f6 100644
--- a/dns/src/auth.rs
+++ b/dns/src/auth.rs
@@ -1,7 +1,4 @@
-use actix_web::{dev::ServiceRequest, web, Error, HttpMessage};
-use actix_web_httpauth::extractors::bearer::BearerAuth;
-use actix_web_httpauth::extractors::AuthenticationError;
-use actix_web_httpauth::headers::www_authenticate::bearer::Bearer;
+use gurt::prelude::*;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use bcrypt::{hash, verify, DEFAULT_COST};
@@ -42,7 +39,7 @@ pub struct UserInfo {
pub created_at: DateTime
,
}
-pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> Result {
+pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> std::result::Result {
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@@ -57,7 +54,7 @@ pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> Result Result {
+pub fn validate_jwt(token: &str, secret: &str) -> std::result::Result {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
@@ -65,31 +62,39 @@ pub fn validate_jwt(token: &str, secret: &str) -> Result Result {
+pub fn hash_password(password: &str) -> std::result::Result {
hash(password, DEFAULT_COST)
}
-pub fn verify_password(password: &str, hash: &str) -> Result {
+pub fn verify_password(password: &str, hash: &str) -> std::result::Result {
verify(password, hash)
}
-pub async fn jwt_middleware(
- req: ServiceRequest,
- credentials: BearerAuth,
-) -> Result {
- let jwt_secret = req
- .app_data::>()
- .unwrap()
- .as_ref();
+pub async fn jwt_middleware_gurt(ctx: &ServerContext, jwt_secret: &str) -> Result {
+ let start_time = std::time::Instant::now();
+ log::info!("JWT middleware started for {} {}", ctx.method(), ctx.path());
+
+ let auth_header = ctx.header("authorization")
+ .or_else(|| ctx.header("Authorization"))
+ .ok_or_else(|| {
+ log::warn!("JWT middleware failed: Missing Authorization header in {:?}", start_time.elapsed());
+ GurtError::invalid_message("Missing Authorization header")
+ })?;
- match validate_jwt(credentials.token(), jwt_secret) {
- Ok(claims) => {
- req.extensions_mut().insert(claims);
- Ok(req)
- }
- Err(_) => {
- let config = AuthenticationError::new(Bearer::default());
- Err((Error::from(config), req))
- }
+ if !auth_header.starts_with("Bearer ") {
+ log::warn!("JWT middleware failed: Invalid header format in {:?}", start_time.elapsed());
+ return Err(GurtError::invalid_message("Invalid Authorization header format"));
}
+
+ let token = &auth_header[7..]; // Remove "Bearer " prefix
+
+ let result = validate_jwt(token, jwt_secret)
+ .map_err(|e| GurtError::invalid_message(format!("Invalid JWT token: {}", e)));
+
+ match &result {
+ Ok(_) => log::info!("JWT middleware completed successfully in {:?}", start_time.elapsed()),
+ Err(e) => log::warn!("JWT middleware failed: {} in {:?}", e, start_time.elapsed()),
+ }
+
+ result
}
\ No newline at end of file
diff --git a/dns/src/config/mod.rs b/dns/src/config/mod.rs
index 6fc9e11..7bac028 100644
--- a/dns/src/config/mod.rs
+++ b/dns/src/config/mod.rs
@@ -25,6 +25,8 @@ impl Config {
url: "postgresql://username:password@localhost/domains".into(),
max_connections: 10,
},
+ cert_path: "localhost+2.pem".into(),
+ key_path: "localhost+2-key.pem".into(),
},
discord: Discord {
bot_token: "".into(),
diff --git a/dns/src/config/structs.rs b/dns/src/config/structs.rs
index c53e31c..ef20887 100644
--- a/dns/src/config/structs.rs
+++ b/dns/src/config/structs.rs
@@ -15,6 +15,8 @@ pub struct Server {
pub(crate) address: String,
pub(crate) port: u64,
pub(crate) database: Database,
+ pub(crate) cert_path: String,
+ pub(crate) key_path: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
diff --git a/dns/src/gurt_server.rs b/dns/src/gurt_server.rs
new file mode 100644
index 0000000..12dc527
--- /dev/null
+++ b/dns/src/gurt_server.rs
@@ -0,0 +1,216 @@
+mod auth_routes;
+mod helpers;
+mod models;
+mod routes;
+
+use crate::{auth::jwt_middleware_gurt, config::Config, discord_bot};
+use colored::Colorize;
+use macros_rs::fmt::{crashln, string};
+use std::{net::IpAddr, str::FromStr, sync::Arc, collections::HashMap};
+use gurt::prelude::*;
+use gurt::{GurtStatusCode, Route};
+
+#[derive(Clone)]
+pub(crate) struct AppState {
+ trusted: IpAddr,
+ config: Config,
+ db: sqlx::PgPool,
+ jwt_secret: String,
+}
+
+impl AppState {
+ pub fn new(trusted: IpAddr, config: Config, db: sqlx::PgPool, jwt_secret: String) -> Self {
+ Self {
+ trusted,
+ config,
+ db,
+ jwt_secret,
+ }
+ }
+}
+
+#[derive(Clone)]
+pub(crate) struct RateLimitState {
+ limits: Arc>>>>,
+}
+
+impl RateLimitState {
+ pub fn new() -> Self {
+ Self {
+ limits: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
+ }
+ }
+
+ pub async fn check_rate_limit(&self, key: &str, window_secs: i64, max_requests: usize) -> bool {
+ let mut limits = self.limits.write().await;
+ let now = chrono::Utc::now();
+ let window_start = now - chrono::Duration::seconds(window_secs);
+
+ let entry = limits.entry(key.to_string()).or_insert_with(Vec::new);
+
+ entry.retain(|×tamp| timestamp > window_start);
+
+ if entry.len() >= max_requests {
+ false
+ } else {
+ entry.push(now);
+ true
+ }
+ }
+}
+
+struct AppHandler {
+ app_state: AppState,
+ rate_limit_state: Option,
+ handler_type: HandlerType,
+}
+
+// Macro to reduce JWT middleware duplication
+macro_rules! handle_authenticated {
+ ($ctx:expr, $app_state:expr, $handler:expr) => {
+ match jwt_middleware_gurt(&$ctx, &$app_state.jwt_secret).await {
+ Ok(claims) => $handler(&$ctx, $app_state, claims).await,
+ Err(e) => Ok(GurtResponse::new(GurtStatusCode::Unauthorized)
+ .with_string_body(&format!("Authentication failed: {}", e))),
+ }
+ };
+}
+
+#[derive(Clone)]
+enum HandlerType {
+ Index,
+ GetDomain,
+ GetDomains,
+ GetTlds,
+ CheckDomain,
+ Register,
+ Login,
+ GetUserInfo,
+ CreateInvite,
+ RedeemInvite,
+ CreateDomainInvite,
+ RedeemDomainInvite,
+ CreateDomain,
+ UpdateDomain,
+ DeleteDomain,
+}
+
+impl GurtHandler for AppHandler {
+ fn handle(&self, ctx: &ServerContext) -> std::pin::Pin> + Send + '_>> {
+ let app_state = self.app_state.clone();
+ let rate_limit_state = self.rate_limit_state.clone();
+ let handler_type = self.handler_type.clone();
+
+ let ctx_data = (
+ ctx.remote_addr,
+ ctx.request.clone(),
+ );
+
+ Box::pin(async move {
+ let start_time = std::time::Instant::now();
+ let ctx = ServerContext {
+ remote_addr: ctx_data.0,
+ request: ctx_data.1,
+ };
+
+ log::info!("Handler started for {} {} from {}", ctx.method(), ctx.path(), ctx.remote_addr);
+
+ let result = match handler_type {
+ HandlerType::Index => routes::index(app_state).await,
+ HandlerType::GetDomain => routes::get_domain(&ctx, app_state).await,
+ HandlerType::GetDomains => routes::get_domains(&ctx, app_state).await,
+ HandlerType::GetTlds => routes::get_tlds(app_state).await,
+ HandlerType::CheckDomain => routes::check_domain(&ctx, app_state).await,
+ HandlerType::Register => auth_routes::register(&ctx, app_state).await,
+ HandlerType::Login => auth_routes::login(&ctx, app_state).await,
+ HandlerType::GetUserInfo => handle_authenticated!(ctx, app_state, auth_routes::get_user_info),
+ HandlerType::CreateInvite => handle_authenticated!(ctx, app_state, auth_routes::create_invite),
+ HandlerType::RedeemInvite => handle_authenticated!(ctx, app_state, auth_routes::redeem_invite),
+ HandlerType::CreateDomainInvite => handle_authenticated!(ctx, app_state, auth_routes::create_domain_invite),
+ HandlerType::RedeemDomainInvite => handle_authenticated!(ctx, app_state, auth_routes::redeem_domain_invite),
+ HandlerType::CreateDomain => {
+ // Check rate limit first
+ if let Some(ref rate_limit_state) = rate_limit_state {
+ let client_ip = ctx.client_ip().to_string();
+ if !rate_limit_state.check_rate_limit(&client_ip, 600, 5).await {
+ return Ok(GurtResponse::new(GurtStatusCode::TooLarge).with_string_body("Rate limit exceeded: 5 requests per 10 minutes"));
+ }
+ }
+
+ handle_authenticated!(ctx, app_state, routes::create_domain)
+ },
+ HandlerType::UpdateDomain => handle_authenticated!(ctx, app_state, routes::update_domain),
+ HandlerType::DeleteDomain => handle_authenticated!(ctx, app_state, routes::delete_domain),
+ };
+
+ let duration = start_time.elapsed();
+ match &result {
+ Ok(response) => {
+ log::info!("Handler completed for {} {} in {:?} - Status: {}",
+ ctx.method(), ctx.path(), duration, response.status_code);
+ },
+ Err(e) => {
+ log::error!("Handler failed for {} {} in {:?} - Error: {}",
+ ctx.method(), ctx.path(), duration, e);
+ }
+ }
+
+ result
+ })
+ }
+}
+
+pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
+ let config = Config::new().set_path(&cli.config).read();
+
+ let trusted_ip = match IpAddr::from_str(&config.server.address) {
+ Ok(addr) => addr,
+ Err(err) => crashln!("Cannot parse address.\n{}", string!(err).white()),
+ };
+
+ let db = match config.connect_to_db().await {
+ Ok(pool) => pool,
+ Err(err) => crashln!("Failed to connect to PostgreSQL database.\n{}", string!(err).white()),
+ };
+
+ // Start Discord bot
+ if !config.discord.bot_token.is_empty() {
+ if let Err(e) = discord_bot::start_discord_bot(config.discord.bot_token.clone(), db.clone()).await {
+ log::error!("Failed to start Discord bot: {}", e);
+ }
+ }
+
+ let jwt_secret = config.auth.jwt_secret.clone();
+ let app_state = AppState::new(trusted_ip, config.clone(), db, jwt_secret);
+ let rate_limit_state = RateLimitState::new();
+
+ // Create GURT server
+ let mut server = GurtServer::new();
+
+ // Load TLS certificates
+ if let Err(e) = server.load_tls_certificates(&config.server.cert_path, &config.server.key_path) {
+ crashln!("Failed to load TLS certificates: {}", e);
+ }
+
+ server = server
+ .route(Route::get("/"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::Index })
+ .route(Route::get("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetDomain })
+ .route(Route::get("/domains"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetDomains })
+ .route(Route::get("/tlds"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetTlds })
+ .route(Route::get("/check"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CheckDomain })
+ .route(Route::post("/auth/register"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::Register })
+ .route(Route::post("/auth/login"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::Login })
+ .route(Route::get("/auth/me"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetUserInfo })
+ .route(Route::post("/auth/invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CreateInvite })
+ .route(Route::post("/auth/redeem-invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::RedeemInvite })
+ .route(Route::post("/auth/create-domain-invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CreateDomainInvite })
+ .route(Route::post("/auth/redeem-domain-invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::RedeemDomainInvite })
+ .route(Route::post("/domain"), AppHandler { app_state: app_state.clone(), rate_limit_state: Some(rate_limit_state), handler_type: HandlerType::CreateDomain })
+ .route(Route::put("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::UpdateDomain })
+ .route(Route::delete("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::DeleteDomain });
+
+ log::info!("GURT server listening on {}", config.get_address());
+ server.listen(&config.get_address()).await.map_err(|e| {
+ std::io::Error::new(std::io::ErrorKind::Other, format!("GURT server error: {}", e))
+ })
+}
\ No newline at end of file
diff --git a/dns/src/gurt_server/auth_routes.rs b/dns/src/gurt_server/auth_routes.rs
new file mode 100644
index 0000000..b54afdc
--- /dev/null
+++ b/dns/src/gurt_server/auth_routes.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 {
+ // 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 {
+ 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,
+}
\ No newline at end of file
diff --git a/dns/src/gurt_server/helpers.rs b/dns/src/gurt_server/helpers.rs
new file mode 100644
index 0000000..908fd5c
--- /dev/null
+++ b/dns/src/gurt_server/helpers.rs
@@ -0,0 +1,20 @@
+use gurt::prelude::*;
+
+use std::net::IpAddr;
+
+pub fn validate_ip(domain: &super::models::Domain) -> Result<()> {
+ if domain.ip.parse::().is_err() {
+ return Err(GurtError::invalid_message("Invalid IP address"));
+ }
+
+ Ok(())
+}
+
+pub fn deserialize_lowercase<'de, D>(deserializer: D) -> std::result::Result
+where
+ D: serde::Deserializer<'de>,
+{
+ use serde::Deserialize;
+ let s = String::deserialize(deserializer)?;
+ Ok(s.to_lowercase())
+}
\ No newline at end of file
diff --git a/dns/src/http/models.rs b/dns/src/gurt_server/models.rs
similarity index 82%
rename from dns/src/http/models.rs
rename to dns/src/gurt_server/models.rs
index 05b0c0b..c9acf5c 100644
--- a/dns/src/http/models.rs
+++ b/dns/src/gurt_server/models.rs
@@ -80,7 +80,7 @@ pub(crate) struct ResponseDnsRecord {
pub(crate) record_type: String,
pub(crate) name: String,
pub(crate) value: String,
- pub(crate) ttl: i32,
+ pub(crate) ttl: Option,
pub(crate) priority: Option,
}
@@ -89,27 +89,6 @@ pub(crate) struct UpdateDomain {
pub(crate) ip: String,
}
-#[derive(Serialize)]
-pub(crate) struct Error {
- pub(crate) msg: &'static str,
- pub(crate) error: String,
-}
-
-#[derive(Serialize)]
-pub(crate) struct Ratelimit {
- pub(crate) msg: String,
- pub(crate) error: &'static str,
- pub(crate) after: u64,
-}
-
-#[derive(Deserialize)]
-pub(crate) struct PaginationParams {
- #[serde(alias = "p", alias = "doc")]
- pub(crate) page: Option,
- #[serde(alias = "s", alias = "size", alias = "l", alias = "limit")]
- pub(crate) page_size: Option,
-}
-
#[derive(Serialize)]
pub(crate) struct PaginationResponse {
pub(crate) domains: Vec,
@@ -117,12 +96,6 @@ pub(crate) struct PaginationResponse {
pub(crate) limit: u32,
}
-#[derive(Deserialize)]
-pub(crate) struct DomainQuery {
- pub(crate) name: String,
- pub(crate) tld: Option,
-}
-
#[derive(Serialize)]
pub(crate) struct DomainList {
pub(crate) domain: String,
diff --git a/dns/src/gurt_server/routes.rs b/dns/src/gurt_server/routes.rs
new file mode 100644
index 0000000..8b2e7b3
--- /dev/null
+++ b/dns/src/gurt_server/routes.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 = 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 {
+ // 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::().ok())
+ .unwrap_or(1)
+ .max(1); // Ensure page is at least 1
+
+ let page_size = query_params.get("limit")
+ .and_then(|l| l.parse::().ok())
+ .unwrap_or(100)
+ .clamp(1, 1000); // Limit between 1 and 1000
+
+ let offset = (page - 1) * page_size;
+
+ let domains: Vec = 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 = 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 {
+ Ok(GurtResponse::ok().with_json_body(&app_state.config.tld_list())?)
+}
+
+pub(crate) async fn check_domain(ctx: &ServerContext, app_state: AppState) -> Result {
+ 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=&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 = 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 {
+ 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 = 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 {
+ 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 = 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,
+}
\ No newline at end of file
diff --git a/dns/src/http.rs b/dns/src/http.rs
deleted file mode 100644
index d0f4bdc..0000000
--- a/dns/src/http.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-mod auth_routes;
-mod helpers;
-mod models;
-mod ratelimit;
-mod routes;
-
-use crate::{auth::jwt_middleware, config::Config, discord_bot};
-use actix_governor::{Governor, GovernorConfigBuilder};
-use actix_web::{http::Method, web, web::Data, App, HttpServer};
-use actix_web_httpauth::middleware::HttpAuthentication;
-use colored::Colorize;
-use macros_rs::fmt::{crashln, string};
-use ratelimit::RealIpKeyExtractor;
-use std::{net::IpAddr, str::FromStr, time::Duration};
-
-// Domain struct is now defined in models.rs
-
-#[derive(Clone)]
-pub(crate) struct AppState {
- trusted: IpAddr,
- config: Config,
- db: sqlx::PgPool,
-}
-
-
-#[actix_web::main]
-pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
- let config = Config::new().set_path(&cli.config).read();
-
- let trusted_ip = match IpAddr::from_str(&config.server.address) {
- Ok(addr) => addr,
- Err(err) => crashln!("Cannot parse address.\n{}", string!(err).white()),
- };
-
- let governor_builder = GovernorConfigBuilder::default()
- .methods(vec![Method::POST])
- .period(Duration::from_secs(600))
- .burst_size(5)
- .key_extractor(RealIpKeyExtractor)
- .finish()
- .unwrap();
-
- let db = match config.connect_to_db().await {
- Ok(pool) => pool,
- Err(err) => crashln!("Failed to connect to PostgreSQL database.\n{}", string!(err).white()),
- };
-
- // Start Discord bot
- if !config.discord.bot_token.is_empty() {
- if let Err(e) = discord_bot::start_discord_bot(config.discord.bot_token.clone(), db.clone()).await {
- log::error!("Failed to start Discord bot: {}", e);
- }
- }
-
- let auth_middleware = HttpAuthentication::bearer(jwt_middleware);
- let jwt_secret = config.auth.jwt_secret.clone();
-
- let app = move || {
- let data = AppState {
- db: db.clone(),
- trusted: trusted_ip,
- config: Config::new().set_path(&cli.config).read(),
- };
-
- App::new()
- .app_data(Data::new(data))
- .app_data(Data::new(jwt_secret.clone()))
- // Public routes
- .service(routes::index)
- .service(routes::get_domain)
- .service(routes::get_domains)
- .service(routes::get_tlds)
- .service(routes::check_domain)
- // Auth routes
- .service(auth_routes::register)
- .service(auth_routes::login)
- // Protected routes
- .service(
- web::scope("")
- .wrap(auth_middleware.clone())
- .service(auth_routes::get_user_info)
- .service(auth_routes::create_invite)
- .service(auth_routes::redeem_invite)
- .service(auth_routes::create_domain_invite)
- .service(auth_routes::redeem_domain_invite)
- .service(routes::update_domain)
- .service(routes::delete_domain)
- .route("/domain", web::post().to(routes::create_domain).wrap(Governor::new(&governor_builder)))
- )
- };
-
- log::info!("Listening on {}", config.get_address());
- HttpServer::new(app).bind(config.get_address())?.run().await
-}
diff --git a/dns/src/http/auth_routes.rs b/dns/src/http/auth_routes.rs
deleted file mode 100644
index 19f6962..0000000
--- a/dns/src/http/auth_routes.rs
+++ /dev/null
@@ -1,547 +0,0 @@
-use super::{models::*, AppState};
-use crate::auth::*;
-use actix_web::{web, HttpResponse, Responder, HttpRequest, HttpMessage};
-use sqlx::Row;
-use rand::Rng;
-use chrono::Utc;
-
-#[actix_web::post("/auth/register")]
-pub(crate) async fn register(
- user: web::Json,
- app: web::Data
-) -> impl Responder {
- 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 HttpResponse::InternalServerError().json(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.db)
- .await;
-
- match user_result {
- Ok(row) => {
- let user_id: i32 = row.get("id");
-
-
- // Generate JWT
- match generate_jwt(user_id, &user.username, &app.config.auth.jwt_secret) {
- Ok(token) => {
- HttpResponse::Ok().json(LoginResponse {
- token,
- user: UserInfo {
- id: user_id,
- username: user.username.clone(),
- registrations_remaining: registrations,
- domain_invite_codes: 3,
- created_at: Utc::now(),
- },
- })
- }
- Err(_) => HttpResponse::InternalServerError().json(Error {
- msg: "Failed to generate token",
- error: "TOKEN_ERROR".into(),
- }),
- }
- }
- Err(sqlx::Error::Database(db_err)) => {
- if db_err.is_unique_violation() {
- HttpResponse::Conflict().json(Error {
- msg: "Username already exists",
- error: "USER_EXISTS".into(),
- })
- } else {
- HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- })
- }
- }
- Err(_) => HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- }),
- }
-}
-
-#[actix_web::post("/auth/login")]
-pub(crate) async fn login(
- credentials: web::Json,
- app: web::Data
-) -> impl Responder {
- match sqlx::query_as::<_, User>(
- "SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE username = $1"
- )
- .bind(&credentials.username)
- .fetch_optional(&app.db)
- .await
- {
- Ok(Some(user)) => {
- match verify_password(&credentials.password, &user.password_hash) {
- Ok(true) => {
- match generate_jwt(user.id, &user.username, &app.config.auth.jwt_secret) {
- Ok(token) => {
- HttpResponse::Ok().json(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,
- },
- })
- }
- Err(e) => {
- eprintln!("JWT generation error: {:?}", e);
- HttpResponse::InternalServerError().json(Error {
- msg: "Failed to generate token",
- error: "TOKEN_ERROR".into(),
- })
- },
- }
- }
- Ok(false) | Err(_) => {
- HttpResponse::Unauthorized().json(Error {
- msg: "Invalid credentials",
- error: "INVALID_CREDENTIALS".into(),
- })
- }
- }
- }
- Ok(None) => {
- HttpResponse::Unauthorized().json(Error {
- msg: "Invalid credentials",
- error: "INVALID_CREDENTIALS".into(),
- })
- }
- Err(e) => {
- eprintln!("Database error: {:?}", e);
- HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- })
- },
- }
-}
-
-#[actix_web::get("/auth/me")]
-pub(crate) async fn get_user_info(
- req: HttpRequest,
- app: web::Data
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- match 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.db)
- .await
- {
- Ok(Some(user)) => {
- HttpResponse::Ok().json(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(None) => HttpResponse::NotFound().json(Error {
- msg: "User not found",
- error: "USER_NOT_FOUND".into(),
- }),
- Err(_) => HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- }),
- }
-}
-
-#[actix_web::post("/auth/invite")]
-pub(crate) async fn create_invite(
- req: HttpRequest,
- app: web::Data
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- // Generate random invite code
- let invite_code: String = rand::thread_rng()
- .sample_iter(&rand::distributions::Alphanumeric)
- .take(16)
- .map(char::from)
- .collect();
-
- // Create invite code (no registration cost)
- match sqlx::query(
- "INSERT INTO invite_codes (code, created_by) VALUES ($1, $2)"
- )
- .bind(&invite_code)
- .bind(claims.user_id)
- .execute(&app.db)
- .await
- {
- Ok(_) => {},
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to create invite code",
- error: "DB_ERROR".into(),
- });
- }
- }
-
- HttpResponse::Ok().json(serde_json::json!({
- "invite_code": invite_code
- }))
-}
-
-#[actix_web::post("/auth/redeem-invite")]
-pub(crate) async fn redeem_invite(
- invite_request: web::Json,
- req: HttpRequest,
- app: web::Data
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- let invite_code = match invite_request.get("invite_code").and_then(|v| v.as_str()) {
- Some(code) => code,
- None => {
- return HttpResponse::BadRequest().json(Error {
- msg: "Invite code is required",
- error: "INVITE_CODE_REQUIRED".into(),
- });
- }
- };
-
- // Find and validate invite code
- let invite = match 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.db)
- .await
- {
- Ok(Some(invite)) => invite,
- Ok(None) => {
- return HttpResponse::BadRequest().json(Error {
- msg: "Invalid or already used invite code",
- error: "INVALID_INVITE".into(),
- });
- }
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Start transaction to redeem invite
- let mut tx = match app.db.begin().await {
- Ok(tx) => tx,
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Mark invite as used
- if let Err(_) = sqlx::query(
- "UPDATE invite_codes SET used_by = $1, used_at = CURRENT_TIMESTAMP WHERE id = $2"
- )
- .bind(claims.user_id)
- .bind(invite.id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to redeem invite code",
- error: "DB_ERROR".into(),
- });
- }
-
- // Add registrations to user (3 registrations per invite)
- if let Err(_) = sqlx::query(
- "UPDATE users SET registrations_remaining = registrations_remaining + 3 WHERE id = $1"
- )
- .bind(claims.user_id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to add registrations",
- error: "DB_ERROR".into(),
- });
- }
-
- if let Err(_) = tx.commit().await {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Transaction failed",
- error: "DB_ERROR".into(),
- });
- }
-
- HttpResponse::Ok().json(serde_json::json!({
- "message": "Invite code redeemed successfully",
- "registrations_added": 3
- }))
-}
-
-#[actix_web::post("/auth/domain-invite")]
-pub(crate) async fn create_domain_invite(
- req: HttpRequest,
- app: web::Data
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- // Check if user has domain invite codes remaining
- let user = match 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.db)
- .await
- {
- Ok(Some(user)) => user,
- Ok(None) => {
- return HttpResponse::NotFound().json(Error {
- msg: "User not found",
- error: "USER_NOT_FOUND".into(),
- });
- }
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- if user.domain_invite_codes <= 0 {
- return HttpResponse::BadRequest().json(Error {
- msg: "No domain invite codes remaining",
- error: "NO_DOMAIN_INVITES".into(),
- });
- }
-
- // Generate random domain invite code
- let invite_code: String = rand::thread_rng()
- .sample_iter(&rand::distributions::Alphanumeric)
- .take(16)
- .map(char::from)
- .collect();
-
- // Start transaction
- let mut tx = match app.db.begin().await {
- Ok(tx) => tx,
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Create domain invite code
- if let Err(_) = sqlx::query(
- "INSERT INTO domain_invite_codes (code, created_by) VALUES ($1, $2)"
- )
- .bind(&invite_code)
- .bind(claims.user_id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to create domain invite code",
- error: "DB_ERROR".into(),
- });
- }
-
- // Decrease user's domain invite codes
- if let Err(_) = sqlx::query(
- "UPDATE users SET domain_invite_codes = domain_invite_codes - 1 WHERE id = $1"
- )
- .bind(claims.user_id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to update domain invite codes",
- error: "DB_ERROR".into(),
- });
- }
-
- if let Err(_) = tx.commit().await {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Transaction failed",
- error: "DB_ERROR".into(),
- });
- }
-
- HttpResponse::Ok().json(serde_json::json!({
- "domain_invite_code": invite_code
- }))
-}
-
-#[actix_web::post("/auth/redeem-domain-invite")]
-pub(crate) async fn redeem_domain_invite(
- invite_request: web::Json,
- req: HttpRequest,
- app: web::Data
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- let invite_code = match invite_request.get("domain_invite_code").and_then(|v| v.as_str()) {
- Some(code) => code,
- None => {
- return HttpResponse::BadRequest().json(Error {
- msg: "Domain invite code is required",
- error: "DOMAIN_INVITE_CODE_REQUIRED".into(),
- });
- }
- };
-
- // Find and validate domain invite code
- let invite = match 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.db)
- .await
- {
- Ok(Some(invite)) => invite,
- Ok(None) => {
- return HttpResponse::BadRequest().json(Error {
- msg: "Invalid or already used domain invite code",
- error: "INVALID_DOMAIN_INVITE".into(),
- });
- }
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Start transaction to redeem invite
- let mut tx = match app.db.begin().await {
- Ok(tx) => tx,
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Mark domain invite as used
- if let Err(_) = sqlx::query(
- "UPDATE domain_invite_codes SET used_by = $1, used_at = CURRENT_TIMESTAMP WHERE id = $2"
- )
- .bind(claims.user_id)
- .bind(invite.id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to redeem domain invite code",
- error: "DB_ERROR".into(),
- });
- }
-
- // Add domain invite codes to user (1 per domain invite)
- if let Err(_) = sqlx::query(
- "UPDATE users SET domain_invite_codes = domain_invite_codes + 1 WHERE id = $1"
- )
- .bind(claims.user_id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to add domain invite codes",
- error: "DB_ERROR".into(),
- });
- }
-
- if let Err(_) = tx.commit().await {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Transaction failed",
- error: "DB_ERROR".into(),
- });
- }
-
- HttpResponse::Ok().json(serde_json::json!({
- "message": "Domain invite code redeemed successfully",
- "domain_invite_codes_added": 1
- }))
-}
\ No newline at end of file
diff --git a/dns/src/http/helpers.rs b/dns/src/http/helpers.rs
deleted file mode 100644
index 0a697df..0000000
--- a/dns/src/http/helpers.rs
+++ /dev/null
@@ -1,72 +0,0 @@
-use super::{models::*, AppState};
-use actix_web::{web::Data, HttpResponse};
-use regex::Regex;
-use serde::Deserialize;
-use std::net::{Ipv4Addr, Ipv6Addr};
-
-pub fn validate_ip(domain: &Domain) -> Result<(), HttpResponse> {
- let valid_url = Regex::new(r"(?i)\bhttps?://[-a-z0-9+&@#/%?=~_|!:,.;]*[-a-z0-9+&@#/%=~_|]").unwrap();
-
- let is_valid_ip = domain.ip.parse::().is_ok() || domain.ip.parse::().is_ok();
- let is_valid_url = valid_url.is_match(&domain.ip);
-
- if is_valid_ip || is_valid_url {
- if domain.name.len() <= 100 {
- Ok(())
- } else {
- Err(HttpResponse::BadRequest().json(Error {
- msg: "Failed to create domain",
- error: "Invalid name, non-existent TLD, or name too long (100 chars).".into(),
- }))
- }
- } else {
- Err(HttpResponse::BadRequest().json(Error {
- msg: "Failed to create domain",
- error: "Invalid name, non-existent TLD, or name too long (100 chars).".into(),
- }))
- }
-}
-
-pub fn deserialize_lowercase<'de, D>(deserializer: D) -> Result
-where
- D: serde::Deserializer<'de>,
-{
- let s = String::deserialize(deserializer)?;
- Ok(s.to_lowercase())
-}
-
-pub async fn is_domain_taken(name: &str, tld: Option<&str>, app: Data) -> Vec {
- if let Some(tld) = tld {
- let count: i64 = sqlx::query_scalar(
- "SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
- )
- .bind(name)
- .bind(tld)
- .fetch_one(&app.db)
- .await
- .unwrap_or(0);
-
- vec![DomainList {
- taken: count > 0,
- domain: format!("{}.{}", name, tld),
- }]
- } else {
- let mut result = Vec::new();
- for tld in &*app.config.tld_list() {
- let count: i64 = sqlx::query_scalar(
- "SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
- )
- .bind(name)
- .bind(tld)
- .fetch_one(&app.db)
- .await
- .unwrap_or(0);
-
- result.push(DomainList {
- taken: count > 0,
- domain: format!("{}.{}", name, tld),
- });
- }
- result
- }
-}
diff --git a/dns/src/http/ratelimit.rs b/dns/src/http/ratelimit.rs
deleted file mode 100644
index fb809df..0000000
--- a/dns/src/http/ratelimit.rs
+++ /dev/null
@@ -1,61 +0,0 @@
-use super::models::Ratelimit;
-use actix_web::{dev::ServiceRequest, web, HttpResponse, HttpResponseBuilder};
-
-use std::{
- net::{IpAddr, SocketAddr},
- str::FromStr,
- time::{SystemTime, UNIX_EPOCH},
-};
-
-use actix_governor::{
- governor::clock::{Clock, DefaultClock, QuantaInstant},
- governor::NotUntil,
- KeyExtractor, SimpleKeyExtractionError,
-};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) struct RealIpKeyExtractor;
-
-impl KeyExtractor for RealIpKeyExtractor {
- type Key = IpAddr;
- type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
-
- fn extract(&self, req: &ServiceRequest) -> Result {
- let reverse_proxy_ip = req
- .app_data::>()
- .map(|ip| ip.get_ref().trusted.to_owned())
- .unwrap_or_else(|| IpAddr::from_str("0.0.0.0").unwrap());
-
- let peer_ip = req.peer_addr().map(|socket| socket.ip());
- let connection_info = req.connection_info();
-
- match peer_ip {
- Some(peer) if peer == reverse_proxy_ip => connection_info
- .realip_remote_addr()
- .ok_or_else(|| SimpleKeyExtractionError::new("Could not extract real IP address from request"))
- .and_then(|str| {
- SocketAddr::from_str(str)
- .map(|socket| socket.ip())
- .or_else(|_| IpAddr::from_str(str))
- .map_err(|_| SimpleKeyExtractionError::new("Could not extract real IP address from request"))
- }),
- _ => connection_info
- .peer_addr()
- .ok_or_else(|| SimpleKeyExtractionError::new("Could not extract peer IP address from request"))
- .and_then(|str| SocketAddr::from_str(str).map_err(|_| SimpleKeyExtractionError::new("Could not extract peer IP address from request")))
- .map(|socket| socket.ip()),
- }
- }
-
- fn exceed_rate_limit_response(&self, negative: &NotUntil, mut response: HttpResponseBuilder) -> HttpResponse {
- let current_unix_timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs();
- let wait_time = negative.wait_time_from(DefaultClock::default().now()).as_secs();
- let wait_time_unix = current_unix_timestamp + negative.wait_time_from(DefaultClock::default().now()).as_secs();
-
- response.json(Ratelimit {
- after: wait_time_unix,
- error: "ratelimited_endpoint",
- msg: format!("Too many requests, try again in {wait_time}s"),
- })
- }
-}
diff --git a/dns/src/http/routes.rs b/dns/src/http/routes.rs
deleted file mode 100644
index 3cc8c1a..0000000
--- a/dns/src/http/routes.rs
+++ /dev/null
@@ -1,400 +0,0 @@
-use super::{models::*, AppState};
-use crate::{auth::Claims, discord_bot::*, http::helpers};
-use std::env;
-
-use actix_web::{
- web::{self, Data},
- HttpRequest, HttpResponse, Responder, HttpMessage,
-};
-
-#[actix_web::get("/")]
-pub(crate) async fn index() -> impl Responder {
- HttpResponse::Ok().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")),
- )
-}
-
-pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -> Result {
- helpers::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 {
- return Err(HttpResponse::BadRequest().json(Error {
- msg: "Failed to create domain",
- error: "Invalid name, non-existent TLD, or name too long (24 chars).".into(),
- }));
- }
-
- if app.config.offen_words().iter().any(|word| domain.name.contains(word)) {
- return Err(HttpResponse::BadRequest().json(Error {
- msg: "Failed to create domain",
- error: "The given domain name is offensive.".into(),
- }));
- }
-
- 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(|_| HttpResponse::InternalServerError().finish())?;
-
- if existing_count > 0 {
- return Err(HttpResponse::Conflict().finish());
- }
-
- 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(|_| HttpResponse::Conflict().finish())?;
-
- Ok(domain)
-}
-
-pub(crate) async fn create_domain(
- domain: web::Json,
- app: Data,
- req: HttpRequest
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- // Check if user has registrations or domain invite codes remaining
- let (user_registrations, user_domain_invites): (i32, i32) = match sqlx::query_as::<_, (i32, i32)>(
- "SELECT registrations_remaining, domain_invite_codes FROM users WHERE id = $1"
- )
- .bind(claims.user_id)
- .fetch_one(&app.db)
- .await
- {
- Ok((registrations, domain_invites)) => (registrations, domain_invites),
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- if user_registrations <= 0 && user_domain_invites <= 0 {
- return HttpResponse::BadRequest().json(Error {
- msg: "No domain registrations or domain invite codes remaining",
- error: "NO_REGISTRATIONS_OR_INVITES".into(),
- });
- }
-
- let domain = domain.into_inner();
-
- match create_logic(domain.clone(), claims.user_id, app.as_ref()).await {
- Ok(_) => {
- // Start transaction for domain registration
- let mut tx = match app.db.begin().await {
- Ok(tx) => tx,
- Err(_) => {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Get the created domain ID
- let domain_id: i32 = match sqlx::query_scalar(
- "SELECT id FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3 ORDER BY created_at DESC LIMIT 1"
- )
- .bind(&domain.name)
- .bind(&domain.tld)
- .bind(claims.user_id)
- .fetch_one(&mut *tx)
- .await
- {
- Ok(id) => id,
- Err(_) => {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to get domain ID",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Get user's current domain invite codes
- let user_domain_invites: i32 = match sqlx::query_scalar(
- "SELECT domain_invite_codes FROM users WHERE id = $1"
- )
- .bind(claims.user_id)
- .fetch_one(&mut *tx)
- .await
- {
- Ok(invites) => invites,
- Err(_) => {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Database error getting user domain invites",
- error: "DB_ERROR".into(),
- });
- }
- };
-
- // Auto-consume domain invite code if available, otherwise use registration
- if user_domain_invites > 0 {
- // Use domain invite code
- if let Err(_) = sqlx::query(
- "UPDATE users SET domain_invite_codes = domain_invite_codes - 1 WHERE id = $1"
- )
- .bind(claims.user_id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to consume domain invite code",
- error: "DB_ERROR".into(),
- });
- }
- } else {
- // Use regular registration
- if let Err(_) = sqlx::query(
- "UPDATE users SET registrations_remaining = registrations_remaining - 1 WHERE id = $1"
- )
- .bind(claims.user_id)
- .execute(&mut *tx)
- .await
- {
- let _ = tx.rollback().await;
- return HttpResponse::InternalServerError().json(Error {
- msg: "Failed to consume registration",
- error: "DB_ERROR".into(),
- });
- }
- }
-
- // Commit the transaction
- if let Err(_) = tx.commit().await {
- return HttpResponse::InternalServerError().json(Error {
- msg: "Transaction failed",
- error: "DB_ERROR".into(),
- });
- }
-
- // Send to Discord for approval
- let registration = DomainRegistration {
- id: domain_id,
- domain_name: domain.name.clone(),
- tld: domain.tld.clone(),
- ip: domain.ip.clone(),
- user_id: claims.user_id,
- username: claims.username.clone(),
- };
-
- let bot_token = app.config.discord.bot_token.clone();
- let channel_id = app.config.discord.channel_id;
-
- tokio::spawn(async move {
- if let Err(e) = send_domain_approval_request(
- channel_id,
- registration,
- &bot_token,
- ).await {
- log::error!("Failed to send Discord message: {}", e);
- }
- });
-
- HttpResponse::Ok().json(serde_json::json!({
- "message": "Domain registration submitted for approval",
- "domain": format!("{}.{}", domain.name, domain.tld),
- "status": "pending"
- }))
- }
- Err(error) => error,
- }
-}
-
-
-#[actix_web::get("/domain/{name}/{tld}")]
-pub(crate) async fn get_domain(path: web::Path<(String, String)>, app: Data) -> impl Responder {
- let (name, tld) = path.into_inner();
-
- match 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.db)
- .await
- {
- Ok(Some(domain)) => HttpResponse::Ok().json(ResponseDomain {
- tld: domain.tld,
- name: domain.name,
- ip: domain.ip,
- records: None,
- }),
- Ok(None) => HttpResponse::NotFound().finish(),
- Err(_) => HttpResponse::InternalServerError().finish(),
- }
-}
-
-#[actix_web::put("/domain/{name}/{tld}")]
-pub(crate) async fn update_domain(
- path: web::Path<(String, String)>,
- domain_update: web::Json,
- app: Data,
- req: HttpRequest
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- let (name, tld) = path.into_inner();
-
- match sqlx::query(
- "UPDATE domains SET ip = $1 WHERE name = $2 AND tld = $3 AND user_id = $4 AND status = 'approved'"
- )
- .bind(&domain_update.ip)
- .bind(&name)
- .bind(&tld)
- .bind(claims.user_id)
- .execute(&app.db)
- .await
- {
- Ok(result) => {
- if result.rows_affected() == 1 {
- HttpResponse::Ok().json(domain_update.into_inner())
- } else {
- HttpResponse::NotFound().json(Error {
- msg: "Domain not found or not owned by user",
- error: "DOMAIN_NOT_FOUND".into(),
- })
- }
- }
- Err(_) => HttpResponse::InternalServerError().finish(),
- }
-}
-
-#[actix_web::delete("/domain/{name}/{tld}")]
-pub(crate) async fn delete_domain(
- path: web::Path<(String, String)>,
- app: Data,
- req: HttpRequest
-) -> impl Responder {
- let extensions = req.extensions();
- let claims = match extensions.get::() {
- Some(claims) => claims,
- None => {
- return HttpResponse::Unauthorized().json(Error {
- msg: "Authentication required",
- error: "AUTH_REQUIRED".into(),
- });
- }
- };
-
- let (name, tld) = path.into_inner();
-
- match 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.db)
- .await
- {
- Ok(result) => {
- if result.rows_affected() == 1 {
- HttpResponse::Ok().finish()
- } else {
- HttpResponse::NotFound().json(Error {
- msg: "Domain not found or not owned by user",
- error: "DOMAIN_NOT_FOUND".into(),
- })
- }
- }
- Err(_) => HttpResponse::InternalServerError().finish(),
- }
-}
-
-#[actix_web::post("/domain/check")]
-pub(crate) async fn check_domain(query: web::Json, app: Data) -> impl Responder {
- let DomainQuery { name, tld } = query.into_inner();
-
- let result = helpers::is_domain_taken(&name, tld.as_deref(), app).await;
- HttpResponse::Ok().json(result)
-}
-
-#[actix_web::get("/domains")]
-pub(crate) async fn get_domains(query: web::Query, app: Data) -> impl Responder {
- let page = query.page.unwrap_or(1);
- let limit = query.page_size.unwrap_or(15);
-
- if page == 0 || limit == 0 {
- return HttpResponse::BadRequest().json(Error {
- msg: "page_size or page must be greater than 0",
- error: "Invalid pagination parameters".into(),
- });
- }
-
- if limit > 100 {
- return HttpResponse::BadRequest().json(Error {
- msg: "page_size must be greater than 0 and less than or equal to 100",
- error: "Invalid pagination parameters".into(),
- });
- }
-
- let offset = (page - 1) * limit;
-
- match 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(limit as i64)
- .bind(offset as i64)
- .fetch_all(&app.db)
- .await
- {
- Ok(domains) => {
- let response_domains: Vec = domains
- .into_iter()
- .map(|domain| ResponseDomain {
- tld: domain.tld,
- name: domain.name,
- ip: domain.ip,
- records: None,
- })
- .collect();
-
- HttpResponse::Ok().json(PaginationResponse {
- domains: response_domains,
- page,
- limit,
- })
- }
- Err(err) => HttpResponse::InternalServerError().json(Error {
- msg: "Failed to fetch domains",
- error: err.to_string(),
- }),
- }
-}
-
-#[actix_web::get("/tlds")]
-pub(crate) async fn get_tlds(app: Data) -> impl Responder { HttpResponse::Ok().json(&*app.config.tld_list()) }
diff --git a/dns/src/main.rs b/dns/src/main.rs
index ad1a9d5..cd8e687 100644
--- a/dns/src/main.rs
+++ b/dns/src/main.rs
@@ -1,5 +1,5 @@
mod config;
-mod http;
+mod gurt_server;
mod secret;
mod auth;
mod discord_bot;
@@ -27,12 +27,11 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
- /// Start the daemon
Start,
}
-
-fn main() {
+#[tokio::main]
+async fn main() {
let cli = Cli::parse();
let mut env = pretty_env_logger::formatted_builder();
let level = cli.verbose.log_level_filter();
@@ -47,7 +46,7 @@ fn main() {
match &cli.command {
Commands::Start => {
- if let Err(err) = http::start(cli) {
+ if let Err(err) = gurt_server::start(cli).await {
log::error!("Failed to start server: {err}")
}
}
diff --git a/flumi/.claude/settings.local.json b/flumi/.claude/settings.local.json
new file mode 100644
index 0000000..e4c3d43
--- /dev/null
+++ b/flumi/.claude/settings.local.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "WebSearch",
+ "WebFetch(domain:github.com)"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
\ No newline at end of file
diff --git a/flumi/Scenes/Tags/input.tscn b/flumi/Scenes/Tags/input.tscn
index 223b4f7..9f3a2a0 100644
--- a/flumi/Scenes/Tags/input.tscn
+++ b/flumi/Scenes/Tags/input.tscn
@@ -85,7 +85,6 @@ layout_mode = 1
offset_right = 200.0
offset_bottom = 35.0
theme = ExtResource("2_theme")
-text = "test"
placeholder_text = "Enter text..."
caret_blink = true
diff --git a/flumi/Scripts/B9/CSSParser.gd b/flumi/Scripts/B9/CSSParser.gd
index 35b5c91..cec1f5a 100644
--- a/flumi/Scripts/B9/CSSParser.gd
+++ b/flumi/Scripts/B9/CSSParser.gd
@@ -462,9 +462,33 @@ static func parse_utility_class_internal(rule: CSSRule, utility_name: String) ->
return
# Handle font weight
+ if utility_name == "font-thin":
+ rule.properties["font-thin"] = true
+ return
+ if utility_name == "font-extralight":
+ rule.properties["font-extralight"] = true
+ return
+ if utility_name == "font-light":
+ rule.properties["font-light"] = true
+ return
+ if utility_name == "font-normal":
+ rule.properties["font-normal"] = true
+ return
+ if utility_name == "font-medium":
+ rule.properties["font-medium"] = true
+ return
+ if utility_name == "font-semibold":
+ rule.properties["font-semibold"] = true
+ return
if utility_name == "font-bold":
rule.properties["font-bold"] = true
return
+ if utility_name == "font-extrabold":
+ rule.properties["font-extrabold"] = true
+ return
+ if utility_name == "font-black":
+ rule.properties["font-black"] = true
+ return
# Handle font family
if utility_name == "font-sans":
@@ -478,7 +502,7 @@ static func parse_utility_class_internal(rule: CSSRule, utility_name: String) ->
rule.properties["font-mono"] = true
return
- var reserved_font_styles = ["font-sans", "font-serif", "font-mono", "font-bold", "font-italic"]
+ var reserved_font_styles = ["font-sans", "font-serif", "font-mono", "font-thin", "font-extralight", "font-light", "font-normal", "font-medium", "font-semibold", "font-bold", "font-extrabold", "font-black", "font-italic"]
# Handle custom font families like font-roboto
if utility_name.begins_with("font-") and not utility_name in reserved_font_styles:
var font_name = utility_name.substr(5) # after 'font-'
@@ -521,8 +545,8 @@ static func parse_utility_class_internal(rule: CSSRule, utility_name: String) ->
val = val.substr(1, val.length() - 2)
rule.properties["width"] = SizeUtils.parse_size(val)
return
- # Height
- if utility_name.begins_with("h-"):
+ # Height, but h-full is temporarily disabled since it fucks with Yoga layout engine
+ if utility_name.begins_with("h-") and utility_name != "h-full":
var val = utility_name.substr(2)
if val.begins_with("[") and val.ends_with("]"):
val = val.substr(1, val.length() - 2)
diff --git a/flumi/Scripts/B9/HTMLParser.gd b/flumi/Scripts/B9/HTMLParser.gd
index d4772cc..5cbf6b5 100644
--- a/flumi/Scripts/B9/HTMLParser.gd
+++ b/flumi/Scripts/B9/HTMLParser.gd
@@ -412,47 +412,69 @@ func apply_element_styles(node: Control, element: HTMLElement, parser: HTMLParse
label.text = text
static func apply_element_bbcode_formatting(element: HTMLElement, styles: Dictionary, content: String, parser: HTMLParser = null) -> String:
+ # Apply general styling first (color, font-weight) for all elements
+ var formatted_content = content
+
+ # Apply font weight (bold/semibold/etc)
+ if styles.has("font-bold") and styles["font-bold"]:
+ formatted_content = "[b]" + formatted_content + "[/b]"
+ elif styles.has("font-semibold") and styles["font-semibold"]:
+ formatted_content = "[b]" + formatted_content + "[/b]" # BBCode doesn't have semibold, use bold
+
+ # Apply italic
+ if styles.has("font-italic") and styles["font-italic"]:
+ formatted_content = "[i]" + formatted_content + "[/i]"
+
+ # Apply underline
+ if styles.has("underline") and styles["underline"]:
+ formatted_content = "[u]" + formatted_content + "[/u]"
+
+ # Apply color
+ if styles.has("color"):
+ var color = styles["color"]
+ if typeof(color) == TYPE_COLOR:
+ color = "#" + color.to_html(false)
+ else:
+ color = str(color)
+ formatted_content = "[color=%s]%s[/color]" % [color, formatted_content]
+
+ # Apply tag-specific formatting
match element.tag_name:
"b":
- if styles.has("font-bold") and styles["font-bold"]:
- return "[b]" + content + "[/b]"
+ if not (styles.has("font-bold") and styles["font-bold"]):
+ formatted_content = "[b]" + formatted_content + "[/b]"
"i":
- if styles.has("font-italic") and styles["font-italic"]:
- return "[i]" + content + "[/i]"
+ if not (styles.has("font-italic") and styles["font-italic"]):
+ formatted_content = "[i]" + formatted_content + "[/i]"
"u":
- if styles.has("underline") and styles["underline"]:
- return "[u]" + content + "[/u]"
+ if not (styles.has("underline") and styles["underline"]):
+ formatted_content = "[u]" + formatted_content + "[/u]"
"small":
if styles.has("font-size"):
- return "[font_size=%d]%s[/font_size]" % [styles["font-size"], content]
+ formatted_content = "[font_size=%d]%s[/font_size]" % [styles["font-size"], formatted_content]
else:
- return "[font_size=20]%s[/font_size]" % content
+ formatted_content = "[font_size=20]%s[/font_size]" % formatted_content
"mark":
if styles.has("bg"):
- var color = styles["bg"]
- if typeof(color) == TYPE_COLOR:
- color = color.to_html(false)
- return "[bgcolor=#%s]%s[/bgcolor]" % [color, content]
+ var bg_color = styles["bg"]
+ if typeof(bg_color) == TYPE_COLOR:
+ bg_color = bg_color.to_html(false)
+ formatted_content = "[bgcolor=#%s]%s[/bgcolor]" % [bg_color, formatted_content]
else:
- return "[bgcolor=#FFFF00]%s[/bgcolor]" % content
+ formatted_content = "[bgcolor=#FFFF00]%s[/bgcolor]" % formatted_content
"code":
if styles.has("font-size"):
- return "[font_size=%d][code]%s[/code][/font_size]" % [styles["font-size"], content]
+ formatted_content = "[font_size=%d][code]%s[/code][/font_size]" % [styles["font-size"], formatted_content]
else:
- return "[font_size=20][code]%s[/code][/font_size]" % content
+ formatted_content = "[font_size=20][code]%s[/code][/font_size]" % formatted_content
"a":
var href = element.get_attribute("href")
- var color = "#1a0dab"
- if styles.has("color"):
- var c = styles["color"]
- if typeof(c) == TYPE_COLOR:
- color = "#" + c.to_html(false)
- else:
- color = str(c)
+
if href.length() > 0:
# Pass raw href - URL resolution happens in handle_link_click
- return "[color=%s][url=%s]%s[/url][/color]" % [color, href, content]
- return content
+ formatted_content = "[url=%s]%s[/url]" % [href, formatted_content]
+
+ return formatted_content
static func get_bbcode_with_styles(element: HTMLElement, styles: Dictionary, parser: HTMLParser) -> String:
var text = ""
diff --git a/flumi/Scripts/B9/Lua.gd b/flumi/Scripts/B9/Lua.gd
index 21d6064..46b3ee5 100644
--- a/flumi/Scripts/B9/Lua.gd
+++ b/flumi/Scripts/B9/Lua.gd
@@ -661,18 +661,43 @@ func _handle_text_setting(operation: Dictionary):
if dom_node:
var text_node = get_dom_node(dom_node, "text")
if text_node:
- if text_node.has_method("set_text"):
+ if text_node is RichTextLabel:
+ var formatted_text = element.get_bbcode_formatted_text(dom_parser)
+ formatted_text = "[font_size=24]%s[/font_size]" % formatted_text
+
+ text_node.text = formatted_text
+ text_node.call_deferred("_auto_resize_to_content")
+ elif text_node.has_method("set_text"):
text_node.set_text(text)
elif "text" in text_node:
text_node.text = text
+ if text_node.has_method("_auto_resize_to_content"):
+ text_node.call_deferred("_auto_resize_to_content")
+ else:
+ var rich_text_label = _find_rich_text_label_recursive(dom_node)
+ if rich_text_label:
+ var formatted_text = element.get_bbcode_formatted_text(dom_parser)
+ formatted_text = "[font_size=24]%s[/font_size]" % formatted_text
+
+ rich_text_label.text = formatted_text
+ rich_text_label.call_deferred("_auto_resize_to_content")
+
+func _find_rich_text_label_recursive(node: Node) -> RichTextLabel:
+ if node is RichTextLabel:
+ return node
+
+ for child in node.get_children():
+ var result = _find_rich_text_label_recursive(child)
+ if result:
+ return result
+
+ return null
func _handle_text_getting(operation: Dictionary):
var selector: String = operation.selector
var element = SelectorUtils.find_first_matching(selector, dom_parser.parse_result.all_elements)
if element:
- # Return the element's cached text content from the HTML element
- # This avoids the need for a callback system since we have the text cached
return element.text_content
return ""
diff --git a/flumi/Scripts/Constants.gd b/flumi/Scripts/Constants.gd
index a058658..6b2b4e5 100644
--- a/flumi/Scripts/Constants.gd
+++ b/flumi/Scripts/Constants.gd
@@ -6,7 +6,7 @@ const SECONDARY_COLOR = Color(43/255.0, 43/255.0, 43/255.0, 1)
const HOVER_COLOR = Color(0, 0, 0, 1)
const DEFAULT_CSS = """
-body { text-base text-[#000000] text-left bg-white }
+body { text-base text-[#000000] text-left bg-white font-serif }
h1 { text-5xl font-bold }
h2 { text-4xl font-bold }
h3 { text-3xl font-bold }
@@ -21,7 +21,7 @@ code { text-xl font-mono }
a { text-[#1a0dab] }
pre { text-xl font-mono }
-button { text-[16px] bg-[#1b1b1b] rounded-md text-white hover:bg-[#2a2a2a] active:bg-[#101010] }
+button { text-[16px] bg-[#1b1b1b] rounded-md text-white hover:bg-[#2a2a2a] active:bg-[#101010] px-3 py-1.5 }
button[disabled] { bg-[#666666] text-[#999999] cursor-not-allowed }
"""
diff --git a/flumi/Scripts/GurtProtocol.gd b/flumi/Scripts/GurtProtocol.gd
index 94312c5..4bf3399 100644
--- a/flumi/Scripts/GurtProtocol.gd
+++ b/flumi/Scripts/GurtProtocol.gd
@@ -1,7 +1,7 @@
extends RefCounted
class_name GurtProtocol
-const DNS_API_URL = "http://localhost:8080"
+const DNS_API_URL = "gurt://localhost:8877"
static func is_gurt_domain(url: String) -> bool:
if url.begins_with("gurt://"):
@@ -52,41 +52,39 @@ static func is_ip_address(address: String) -> bool:
return true
static func fetch_domain_info(name: String, tld: String) -> Dictionary:
- var http_request = HTTPRequest.new()
- var tree = Engine.get_main_loop()
- tree.current_scene.add_child(http_request)
+ var path = "/domain/" + name + "/" + tld
+ var dns_address = "localhost:8877"
- http_request.timeout = 5.0
+ print("DNS API URL: gurt://" + dns_address + path)
- var url = DNS_API_URL + "/domain/" + name + "/" + tld
- print("DNS API URL: ", url)
+ var response = await fetch_content_via_gurt_direct(dns_address, path)
- var error = http_request.request(url)
+ if response.has("error"):
+ if "No response from GURT server" in response.error or "Failed to create GURT client" in response.error:
+ return {"error": "DNS server is not responding"}
+ else:
+ return {"error": "Failed to make DNS request"}
- if error != OK:
- print("HTTP request failed with error: ", error)
- http_request.queue_free()
- return {"error": "Failed to make DNS request"}
-
- var response = await http_request.request_completed
- http_request.queue_free()
-
- if response[1] == 0 and response[3].size() == 0:
+ if not response.has("content"):
return {"error": "DNS server is not responding"}
- var http_code = response[1]
- var body = response[3]
-
- if http_code != 200:
- return {"error": "Domain not found or not approved"}
+ var content = response.content
+ if content.is_empty():
+ return {"error": "DNS server is not responding"}
var json = JSON.new()
- var parse_result = json.parse(body.get_string_from_utf8())
+ var parse_result = json.parse(content.get_string_from_utf8())
if parse_result != OK:
return {"error": "Invalid JSON response from DNS server"}
- return json.data
+ var data = json.data
+
+ # Check if the response indicates an error (like 404)
+ if data is Dictionary and data.has("error"):
+ return {"error": "Domain not found or not approved"}
+
+ return data
static func fetch_content_via_gurt(ip: String, path: String = "/") -> Dictionary:
var client = GurtProtocolClient.new()
diff --git a/flumi/Scripts/StyleManager.gd b/flumi/Scripts/StyleManager.gd
index 877dc3f..da47367 100644
--- a/flumi/Scripts/StyleManager.gd
+++ b/flumi/Scripts/StyleManager.gd
@@ -62,16 +62,16 @@ static func apply_element_styles(node: Control, element: HTMLParser.HTMLElement,
node.size_flags_stretch_ratio = percentage_value
else:
node.custom_minimum_size.x = width
- var should_center_h = styles.has("mx-auto") or styles.has("justify-self-center") or (styles.has("text-align") and styles["text-align"] == "center")
- node.size_flags_horizontal = Control.SIZE_SHRINK_CENTER if should_center_h else Control.SIZE_SHRINK_BEGIN
+ node.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ node.set_meta("size_flags_horizontal_set", true)
if height != null:
if SizingUtils.is_percentage(height):
node.size_flags_vertical = Control.SIZE_EXPAND_FILL
else:
node.custom_minimum_size.y = height
- var should_center_v = styles.has("my-auto") or styles.has("align-self-center")
- node.size_flags_vertical = Control.SIZE_SHRINK_CENTER if should_center_v else Control.SIZE_SHRINK_BEGIN
+ node.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
+ node.set_meta("size_flags_vertical_set", true)
node.set_meta("size_flags_set_by_style_manager", true)
elif node is VBoxContainer or node is HBoxContainer or node is Container:
@@ -97,6 +97,9 @@ static func apply_element_styles(node: Control, element: HTMLParser.HTMLElement,
else:
# regular controls
SizingUtils.apply_regular_control_sizing(node, width, height, styles)
+
+ # Apply centering for FlexContainers
+ apply_flexcontainer_centering(node, styles)
if label and label != node:
label.anchors_preset = Control.PRESET_FULL_RECT
@@ -145,7 +148,7 @@ static func apply_element_styles(node: Control, element: HTMLParser.HTMLElement,
if needs_styling:
# If node is a MarginContainer wrapper, get the actual content node for styling
var content_node = node
- if node is MarginContainer and node.name.begins_with("MarginWrapper_"):
+ if node is MarginContainer and node.has_meta("is_margin_wrapper"):
if node.get_child_count() > 0:
content_node = node.get_child(0)
@@ -168,7 +171,7 @@ static func apply_element_styles(node: Control, element: HTMLParser.HTMLElement,
target_node_for_bg.call_deferred("add_background_rect")
else:
var content_node = node
- if node is MarginContainer and node.name.begins_with("MarginWrapper_"):
+ if node is MarginContainer and node.has_meta("is_margin_wrapper"):
if node.get_child_count() > 0:
content_node = node.get_child(0)
@@ -195,6 +198,7 @@ static func apply_element_styles(node: Control, element: HTMLParser.HTMLElement,
apply_transform_properties(transform_target, styles)
+
return node
static func apply_stylebox_to_panel_container(panel_container: PanelContainer, styles: Dictionary) -> void:
@@ -273,12 +277,12 @@ static func clear_styling_metadata(node: Control) -> void:
static func handle_margin_wrapper(node: Control, styles: Dictionary, needs_margin: bool):
var current_wrapper = null
- if node is MarginContainer and node.name.begins_with("MarginWrapper_"):
+ if node is MarginContainer and node.has_meta("is_margin_wrapper"):
current_wrapper = node
elif node.get_parent() and node.get_parent() is MarginContainer:
var parent = node.get_parent()
- if parent.name.begins_with("MarginWrapper_"):
+ if parent.has_meta("is_margin_wrapper"):
current_wrapper = parent
if needs_margin:
@@ -323,6 +327,7 @@ static func remove_margin_wrapper(margin_container: MarginContainer, original_no
static func apply_margin_wrapper(node: Control, styles: Dictionary) -> Control:
var margin_container = MarginContainer.new()
margin_container.name = "MarginWrapper_" + node.name
+ margin_container.set_meta("is_margin_wrapper", true)
var has_explicit_width = styles.has("width")
var has_explicit_height = styles.has("height")
@@ -406,11 +411,16 @@ static func apply_styles_to_label(label: Control, styles: Dictionary, element: H
if not FontManager.loaded_fonts.has(font_family):
# Font not loaded yet, use sans-serif as fallback
var fallback_font = FontManager.get_font("sans-serif")
- apply_font_to_label(label, fallback_font)
+ apply_font_to_label(label, fallback_font, styles)
if font_resource:
- apply_font_to_label(label, font_resource)
-
+ apply_font_to_label(label, font_resource, styles)
+ else:
+ # No custom font family, but check if we need to apply font weight
+ if styles.has("font-thin") or styles.has("font-extralight") or styles.has("font-light") or styles.has("font-normal") or styles.has("font-medium") or styles.has("font-semibold") or styles.has("font-extrabold") or styles.has("font-black"):
+ var default_font = FontManager.get_font("sans-serif")
+ apply_font_to_label(label, default_font, styles)
+
# Apply font size
if styles.has("font-size"):
font_size = int(styles["font-size"])
@@ -487,15 +497,6 @@ static func apply_styles_to_label(label: Control, styles: Dictionary, element: H
label.text = styled_text
-static func apply_flex_container_properties(node: FlexContainer, styles: Dictionary) -> void:
- FlexUtils.apply_flex_container_properties(node, styles)
-
-static func apply_flex_item_properties(node: Control, styles: Dictionary) -> void:
- FlexUtils.apply_flex_item_properties(node, styles)
-
-static func parse_flex_value(val):
- return FlexUtils.parse_flex_value(val)
-
static func apply_body_styles(body: HTMLParser.HTMLElement, parser: HTMLParser, website_container: Control, website_background: Control) -> void:
var styles = parser.get_element_styles_with_inheritance(body, "", [])
@@ -553,8 +554,35 @@ static func apply_body_styles(body: HTMLParser.HTMLElement, parser: HTMLParser,
static func parse_radius(radius_str: String) -> int:
return SizeUtils.parse_radius(radius_str)
-static func apply_font_to_label(label: RichTextLabel, font_resource: Font) -> void:
- label.add_theme_font_override("normal_font", font_resource)
+static func apply_font_to_label(label: RichTextLabel, font_resource: Font, styles: Dictionary = {}) -> void:
+ # Create normal font with appropriate weight
+ var normal_font = SystemFont.new()
+ normal_font.font_names = font_resource.font_names if font_resource is SystemFont else ["Arial"]
+
+ # Set weight based on styles
+ var font_weight = 400 # Default normal weight
+ if styles.has("font-thin"):
+ font_weight = 100
+ elif styles.has("font-extralight"):
+ font_weight = 200
+ elif styles.has("font-light"):
+ font_weight = 300
+ elif styles.has("font-normal"):
+ font_weight = 400
+ elif styles.has("font-medium"):
+ font_weight = 500
+ elif styles.has("font-semibold"):
+ font_weight = 600
+ elif styles.has("font-bold"):
+ font_weight = 700
+ elif styles.has("font-extrabold"):
+ font_weight = 800
+ elif styles.has("font-black"):
+ font_weight = 900
+
+ normal_font.font_weight = font_weight
+
+ label.add_theme_font_override("normal_font", normal_font)
var bold_font = SystemFont.new()
bold_font.font_names = font_resource.font_names if font_resource is SystemFont else ["Arial"]
@@ -761,3 +789,19 @@ static func await_and_restore_transform(node: Control, target_scale: Vector2, ta
node.scale = target_scale
node.rotation = target_rotation
node.pivot_offset = node.size / 2
+
+static func apply_flexcontainer_centering(node: Control, styles: Dictionary) -> void:
+ if not node is FlexContainer:
+ return
+
+ var should_center_h = styles.has("mx-auto") or styles.has("justify-self-center") or (styles.has("text-align") and styles["text-align"] == "center")
+ var should_center_v = styles.has("my-auto") or styles.has("align-self-center")
+
+ if should_center_h and not node.has_meta("size_flags_horizontal_set"):
+ node.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
+
+ if should_center_v and not node.has_meta("size_flags_vertical_set"):
+ node.size_flags_vertical = Control.SIZE_SHRINK_CENTER
+
+ if should_center_h or should_center_v:
+ node.set_meta("size_flags_set_by_style_manager", true)
diff --git a/flumi/Scripts/Utils/FlexUtils.gd b/flumi/Scripts/Utils/FlexUtils.gd
index 30b3504..a5ea22f 100644
--- a/flumi/Scripts/Utils/FlexUtils.gd
+++ b/flumi/Scripts/Utils/FlexUtils.gd
@@ -14,9 +14,14 @@ static func apply_flex_container_properties(node, styles: Dictionary) -> void:
# Flex wrap
if styles.has("flex-wrap"):
match styles["flex-wrap"]:
- "nowrap": node.flex_wrap = FlexContainer.FlexWrap.NoWrap
- "wrap": node.flex_wrap = FlexContainer.FlexWrap.Wrap
- "wrap-reverse": node.flex_wrap = FlexContainer.FlexWrap.WrapReverse
+ "nowrap":
+ node.flex_wrap = FlexContainer.FlexWrap.NoWrap
+ "wrap":
+ node.flex_wrap = FlexContainer.FlexWrap.Wrap
+ # this is probably not needed but i dont feel like testing it
+ node.flex_property_changed("flex_wrap", FlexContainer.FlexWrap.Wrap)
+ "wrap-reverse":
+ node.flex_wrap = FlexContainer.FlexWrap.WrapReverse
# Justify content
if styles.has("justify-content"):
match styles["justify-content"]:
diff --git a/flumi/Scripts/Utils/Lua/Class.gd b/flumi/Scripts/Utils/Lua/Class.gd
index 9e1bf1a..6b1afb8 100644
--- a/flumi/Scripts/Utils/Lua/Class.gd
+++ b/flumi/Scripts/Utils/Lua/Class.gd
@@ -195,6 +195,13 @@ static func trigger_element_restyle(element: HTMLParser.HTMLElement, dom_parser:
var dom_node = dom_parser.parse_result.dom_nodes.get(element_id, null)
if not dom_node:
return
+
+ # Check if element has the "hidden" class before styling
+ var has_hidden_class = false
+ var current_style = element.get_attribute("style", "")
+ if current_style.length() > 0:
+ var style_classes = CSSParser.smart_split_utility_classes(current_style)
+ has_hidden_class = "hidden" in style_classes
# margins, wrappers, etc.
var updated_dom_node = StyleManager.apply_element_styles(dom_node, element, dom_parser)
@@ -204,9 +211,15 @@ static func trigger_element_restyle(element: HTMLParser.HTMLElement, dom_parser:
dom_parser.parse_result.dom_nodes[element_id] = updated_dom_node
dom_node = updated_dom_node
+ # Apply visibility state to the correct node (wrapper or content)
+ if has_hidden_class:
+ dom_node.visible = false
+ else:
+ dom_node.visible = true
+
# Find node
var actual_element_node = dom_node
- if dom_node is MarginContainer and dom_node.name.begins_with("MarginWrapper_"):
+ if dom_node is MarginContainer and dom_node.has_meta("is_margin_wrapper"):
if dom_node.get_child_count() > 0:
actual_element_node = dom_node.get_child(0)
@@ -223,7 +236,7 @@ static func trigger_element_restyle(element: HTMLParser.HTMLElement, dom_parser:
static func update_element_text_content(dom_node: Control, element: HTMLParser.HTMLElement, dom_parser: HTMLParser) -> void:
# Get node
var content_node = dom_node
- if dom_node is MarginContainer and dom_node.name.begins_with("MarginWrapper_"):
+ if dom_node is MarginContainer and dom_node.has_meta("is_margin_wrapper"):
if dom_node.get_child_count() > 0:
content_node = dom_node.get_child(0)
diff --git a/flumi/Scripts/Utils/Lua/DOM.gd b/flumi/Scripts/Utils/Lua/DOM.gd
index 5e8b52d..66293c0 100644
--- a/flumi/Scripts/Utils/Lua/DOM.gd
+++ b/flumi/Scripts/Utils/Lua/DOM.gd
@@ -515,6 +515,12 @@ static func add_element_methods(vm: LuauVM, lua_api: LuaAPI) -> void:
vm.lua_pushcallable(LuaDOMUtils._element_create_tween_wrapper, "element.createTween")
vm.lua_setfield(-2, "createTween")
+ vm.lua_pushcallable(LuaDOMUtils._element_show_wrapper, "element.show")
+ vm.lua_setfield(-2, "show")
+
+ vm.lua_pushcallable(LuaDOMUtils._element_hide_wrapper, "element.hide")
+ vm.lua_setfield(-2, "hide")
+
_add_classlist_support(vm, lua_api)
vm.lua_newtable()
@@ -881,6 +887,24 @@ static func _element_index_wrapper(vm: LuauVM) -> int:
# Fallback to empty array
vm.lua_newtable()
return 1
+ "visible":
+ if lua_api:
+ # Get element ID and find the element
+ vm.lua_getfield(1, "_element_id")
+ var element_id: String = vm.lua_tostring(-1)
+ vm.lua_pop(1)
+
+ var element = lua_api.dom_parser.find_by_id(element_id) if element_id != "body" else lua_api.dom_parser.find_first("body")
+ if element:
+ # Check if element has display: none (hidden class)
+ var class_attr = element.get_attribute("class")
+ var is_hidden = "hidden" in class_attr or element.get_attribute("style").contains("display:none") or element.get_attribute("style").contains("display: none")
+ vm.lua_pushboolean(not is_hidden)
+ return 1
+
+ # Fallback to true (visible by default)
+ vm.lua_pushboolean(true)
+ return 1
_:
# Check for DOM traversal properties first
if lua_api:
@@ -1034,6 +1058,48 @@ static func _element_newindex_wrapper(vm: LuauVM) -> int:
emit_dom_operation(lua_api, operation)
return 0
+ "visible":
+ var is_visible: bool = vm.lua_toboolean(3)
+
+ vm.lua_getfield(1, "_element_id")
+ var element_id: String = vm.lua_tostring(-1)
+ vm.lua_pop(1)
+
+ var element = lua_api.dom_parser.find_by_id(element_id) if element_id != "body" else lua_api.dom_parser.find_first("body")
+ if element:
+ var class_attr = element.get_attribute("class")
+ var classes = class_attr.split(" ") if not class_attr.is_empty() else []
+
+ if is_visible:
+ # Remove hidden class if present
+ var hidden_index = classes.find("hidden")
+ if hidden_index >= 0:
+ classes.remove_at(hidden_index)
+ var new_class_attr = " ".join(classes).strip_edges()
+ element.set_attribute("class", new_class_attr)
+
+ # Update visual element
+ var operation = {
+ "type": "remove_class",
+ "element_id": element_id,
+ "class_name": "hidden"
+ }
+ emit_dom_operation(lua_api, operation)
+ else:
+ # Add hidden class if not present
+ if not "hidden" in classes:
+ classes.append("hidden")
+ var new_class_attr = " ".join(classes).strip_edges()
+ element.set_attribute("class", new_class_attr)
+
+ # Update visual element
+ var operation = {
+ "type": "add_class",
+ "element_id": element_id,
+ "class_name": "hidden"
+ }
+ emit_dom_operation(lua_api, operation)
+ return 0
_:
# Store in table normally
vm.lua_pushvalue(2)
@@ -1041,6 +1107,71 @@ static func _element_newindex_wrapper(vm: LuauVM) -> int:
vm.lua_rawset(1)
return 0
+static func _element_show_wrapper(vm: LuauVM) -> int:
+ var lua_api = vm.get_meta("lua_api") as LuaAPI
+ if not lua_api:
+ return 0
+
+ vm.luaL_checktype(1, vm.LUA_TTABLE)
+
+ vm.lua_getfield(1, "_element_id")
+ var element_id: String = vm.lua_tostring(-1)
+ vm.lua_pop(1)
+
+ var element = lua_api.dom_parser.find_by_id(element_id) if element_id != "body" else lua_api.dom_parser.find_first("body")
+ if element:
+ var class_attr = element.get_attribute("class")
+ var classes = class_attr.split(" ") if not class_attr.is_empty() else []
+
+ # Remove hidden class if present
+ var hidden_index = classes.find("hidden")
+ if hidden_index >= 0:
+ classes.remove_at(hidden_index)
+ var new_class_attr = " ".join(classes).strip_edges()
+ element.set_attribute("class", new_class_attr)
+
+ # Update visual element
+ var operation = {
+ "type": "remove_class",
+ "element_id": element_id,
+ "class_name": "hidden"
+ }
+ emit_dom_operation(lua_api, operation)
+
+ return 0
+
+static func _element_hide_wrapper(vm: LuauVM) -> int:
+ var lua_api = vm.get_meta("lua_api") as LuaAPI
+ if not lua_api:
+ return 0
+
+ vm.luaL_checktype(1, vm.LUA_TTABLE)
+
+ vm.lua_getfield(1, "_element_id")
+ var element_id: String = vm.lua_tostring(-1)
+ vm.lua_pop(1)
+
+ var element = lua_api.dom_parser.find_by_id(element_id) if element_id != "body" else lua_api.dom_parser.find_first("body")
+ if element:
+ var class_attr = element.get_attribute("class")
+ var classes = class_attr.split(" ") if not class_attr.is_empty() else []
+
+ # Add hidden class if not present
+ if not "hidden" in classes:
+ classes.append("hidden")
+ var new_class_attr = " ".join(classes).strip_edges()
+ element.set_attribute("class", new_class_attr)
+
+ # Update visual element
+ var operation = {
+ "type": "add_class",
+ "element_id": element_id,
+ "class_name": "hidden"
+ }
+ emit_dom_operation(lua_api, operation)
+
+ return 0
+
static func _element_create_tween_wrapper(vm: LuauVM) -> int:
var lua_api = vm.get_meta("lua_api") as LuaAPI
if not lua_api:
diff --git a/flumi/Scripts/Utils/Lua/Network.gd b/flumi/Scripts/Utils/Lua/Network.gd
index f687e29..c72a95d 100644
--- a/flumi/Scripts/Utils/Lua/Network.gd
+++ b/flumi/Scripts/Utils/Lua/Network.gd
@@ -126,6 +126,8 @@ static func _response_ok_handler(vm: LuauVM) -> int:
return 1
static func make_http_request(url: String, method: String, headers: PackedStringArray, body: String) -> Dictionary:
+ if url.begins_with("gurt://"):
+ return make_gurt_request(url, method, headers, body)
var http_client = HTTPClient.new()
var response_data = {
"status": 0,
@@ -269,3 +271,63 @@ static func make_http_request(url: String, method: String, headers: PackedString
http_client.close()
return response_data
+
+static var _gurt_client: GurtProtocolClient = null
+
+static func make_gurt_request(url: String, method: String, headers: PackedStringArray, body: String) -> Dictionary:
+ var response_data = {
+ "status": 0,
+ "status_text": "Network Error",
+ "headers": {},
+ "body": ""
+ }
+
+ # Reuse existing client or create new one
+ if _gurt_client == null:
+ _gurt_client = GurtProtocolClient.new()
+ if not _gurt_client.create_client(10):
+ response_data.status = 0
+ response_data.status_text = "Connection Failed"
+ return response_data
+
+ var client = _gurt_client
+
+ # Convert headers array to dictionary
+ var headers_dict = {}
+ for header in headers:
+ var parts = header.split(":", 1)
+ if parts.size() == 2:
+ headers_dict[parts[0].strip_edges()] = parts[1].strip_edges()
+
+ # Prepare request options
+ var options = {
+ "method": method
+ }
+
+ if not headers_dict.is_empty():
+ options["headers"] = headers_dict
+
+ if not body.is_empty():
+ options["body"] = body
+
+ var response = client.request(url, options)
+
+ # Keep connection alive for reuse instead of disconnecting after every request
+ # client.disconnect()
+
+ if not response:
+ response_data.status = 0
+ response_data.status_text = "No Response"
+ return response_data
+
+ response_data.status = response.status_code
+ response_data.status_text = response.status_message if response.status_message else "OK"
+ response_data.headers = response.headers if response.headers else {}
+
+ var body_content = response.body if response.body else ""
+ if body_content is PackedByteArray:
+ response_data.body = body_content.get_string_from_utf8()
+ else:
+ response_data.body = str(body_content)
+
+ return response_data
diff --git a/flumi/Scripts/main.gd b/flumi/Scripts/main.gd
index b537e63..da4fae5 100644
--- a/flumi/Scripts/main.gd
+++ b/flumi/Scripts/main.gd
@@ -55,8 +55,6 @@ func _ready():
DisplayServer.window_set_min_size(MIN_SIZE)
get_viewport().size_changed.connect(_on_viewport_size_changed)
-
- call_deferred("render")
func _on_viewport_size_changed():
recalculate_percentage_elements(website_container)
@@ -305,7 +303,7 @@ func create_element_node(element: HTMLParser.HTMLElement, parser: HTMLParser) ->
return null
final_node = StyleManager.apply_element_styles(final_node, element, parser)
# Flex item properties may still apply
- StyleManager.apply_flex_item_properties(final_node, styles)
+ FlexUtils.apply_flex_item_properties(final_node, styles)
return final_node
if is_flex_container:
@@ -335,6 +333,9 @@ func create_element_node(element: HTMLParser.HTMLElement, parser: HTMLParser) ->
elif not element.text_content.is_empty():
var new_node = await create_element_node_internal(element, parser)
container_for_children.add_child(new_node)
+ # For flex divs, we're done - no additional node creation needed
+ elif element.tag_name == "div":
+ pass
else:
final_node = await create_element_node_internal(element, parser)
if not final_node:
@@ -359,10 +360,10 @@ func create_element_node(element: HTMLParser.HTMLElement, parser: HTMLParser) ->
flex_container_node = first_child
if flex_container_node is FlexContainer:
- StyleManager.apply_flex_container_properties(flex_container_node, styles)
+ FlexUtils.apply_flex_container_properties(flex_container_node, styles)
# Apply flex ITEM properties
- StyleManager.apply_flex_item_properties(final_node, styles)
+ FlexUtils.apply_flex_item_properties(final_node, styles)
# Skip ul/ol and non-flex forms, they handle their own children
var skip_general_processing = false
@@ -473,6 +474,11 @@ func create_element_node_internal(element: HTMLParser.HTMLElement, parser: HTMLP
"div":
var styles = parser.get_element_styles_with_inheritance(element, "", [])
var hover_styles = parser.get_element_styles_with_inheritance(element, "hover", [])
+ var is_flex_container = styles.has("display") and ("flex" in styles["display"])
+
+ # For flex divs, don't create div scene - the AutoSizingFlexContainer handles it
+ if is_flex_container:
+ return null
# Create div container
if BackgroundUtils.needs_background_wrapper(styles) or hover_styles.size() > 0:
diff --git a/flumi/addons/gurt-protocol/bin/windows/gurt_godot.dll b/flumi/addons/gurt-protocol/bin/windows/gurt_godot.dll
index 6593e22424ea663a3f84b064752cdb2a16f81b1e..a5f623eeb9311246918370b466b1061fe2c65fc0 100644
GIT binary patch
delta 1034426
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≶eKrS{$HS=WyC;<9rGV(o=r;X~|3
zpUMXn;dG>{HEw@fvz${+iN(7ee2tw_G+r}%;YLE}>VVL0DvzfW0qdB8xKed9K#OYU
zd%laeU>_vTk~grp@|P6%g3;!iUU)+zZm^lqE{xjMQ(EOY-|YVOs35rp<;6N<8G|1?)O*W3C!#!v*i^Vd
z)!&KDXh5Av1g*j9h><
z!+o9(Xh{fq>*A)h9o(u%h~RmBY=%HQ`VFTwF?fdbBBr1S{eXtWPe>YqEbmXaAY
zs(8fd#H-a#U0RXaqJ?K6N+=H!6)e$&-FdiDmEyD*Kcv(%F)y&_RHLiH&>5*!C
zUG4bt7)?5SdavBX_14qIlU-V2UmQ_`OR>}f!bzNc$*~
zIV5t(oeNKTH2gz&%xyc>_;^ttwR|6ge4HW$<7;U^{ErI!Op^N*La6(aB
zfWO}20ubi)Eg^Za9#xT8-)hz;&bL}6`Zfu)2@nfz3F8rhUjnd5suia;{$Llv5u@)w
zrCiirRI@a&OZ1Y_6TbsJ$znB9qmub(K-CFhxnNBmOfc{;-g+AweK4EU0H9o%aB9S%
zSSz!$xFs2}00U2c1F;h)$z6sLCv4-Q9XX>2C1CX|ZJD^@iJU`%$Z3o$)x}?)Y41)$
zC49b-`0Ks4op)>p3L(_St|B0*tv{5<0mp?kQ@jfot)|6_c=(fG^@KMj(T4>^Bf*Jq
z61)`5LoP5O*=0@@umr}>tNrRx1E#^MsWU9EwBedAB_uunB1C?VhyZs}O(Dk`v_K>u
z4aHsoshZEqK)3RNZp4TGEl#bm4OaZk*>=tEuEb%nDv(ljS+Vu^?rI-smjyNA{4(aY
zr*IHOoo1Vxj57-h`&*R{AIe3O2lC;WjPy7SY#89y+Fy=va@EmC$Bo
ztcS7!^3);%7R`n994jTqFEJ51Pk=nU{w;lf)a(d!hgZ$uD+>n8+9P_TF~=JW?wY{Q
z@z6q``}f$^q249px(i5SB>cS)IMDyYWF&PO=UKZdKnKmxIO0+>npkvd>AvxcNw06iV8F
zRw(wKb98+SKWO$Y!qe1E{!#Pcndivzk6M)F&nkZZTvvA-ia;lx@&bqSGNboheJsAw
zX5WytL@-T+4IA7sk1R(AQ6~;V6HY8x`(v)zs!|j2a}JIdL2gAF?aqoZ(O?}?VxHg&
zY(F81LXF=zpW?0BWtM>B~JD^Oi9Q*sW@efa5rw)dV|Ljo~w6$8Eou
z4vtB{GXXk*3l#K=>4Njupp`QPZh$3C7z!
z-M}}Tb(rD0Gnkk)^bdPM5)#Jx+g!_dF)5
zy%(n!%~s5d{KP(fAtoOb7+>DQTG_rEoNC$4B@-6|jg}gNKr4tG
z)eWtMrHX$;u