feat: HELIOS Remote v5.0.0 Final Release & Systemd Auto-Start Daemon
This commit is contained in:
2
server/.env
Normal file
2
server/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgres://helios_user:helios_password@localhost:5433/helios_db
|
||||
JWT_SECRET=super_secret_key_for_testing_only_1234567890
|
||||
24
server/Cargo.toml
Normal file
24
server/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[[bin]]
|
||||
name = "migrate_test"
|
||||
path = "src/migrate_test.rs"
|
||||
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono"] }
|
||||
jsonwebtoken = "9.0"
|
||||
dotenvy = "0.15"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
bcrypt = "0.15"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
common = { path = "../common" }
|
||||
25
server/migrations/20260722000000_create_users_table.sql
Normal file
25
server/migrations/20260722000000_create_users_table.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Function to update updated_at column automatically
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE update_updated_at_column();
|
||||
26
server/migrations/20260722141302_create_todos_table.sql
Normal file
26
server/migrations/20260722141302_create_todos_table.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Function to update updated_at column automatically
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
DROP TRIGGER IF EXISTS update_todos_updated_at ON todos;
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON todos
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE update_updated_at_column();
|
||||
13
server/migrations/20260726000000_create_devices_table.sql
Normal file
13
server/migrations/20260726000000_create_devices_table.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
hostname VARCHAR(255),
|
||||
ip_address VARCHAR(45),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'offline',
|
||||
last_heartbeat TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
|
||||
63
server/src/auth.rs
Normal file
63
server/src/auth.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
pub struct AuthUser(pub Claims);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, String);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".to_string()))?;
|
||||
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid token type".to_string()));
|
||||
}
|
||||
|
||||
let token = &auth_header[7..];
|
||||
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token".to_string()))?;
|
||||
|
||||
Ok(AuthUser(token_data.claims))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_jwt(user_id: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let expiration = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::hours(24))
|
||||
.expect("valid timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_owned(),
|
||||
exp: expiration,
|
||||
};
|
||||
|
||||
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
|
||||
}
|
||||
89
server/src/handlers/auth_handler.rs
Normal file
89
server/src/handlers/auth_handler.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use crate::auth::create_jwt;
|
||||
use crate::models::{AuthResponse, LoginRequest, RegisterRequest, User};
|
||||
use crate::AppState;
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<AuthResponse>), (StatusCode, String)> {
|
||||
// 1. Check if user already exists
|
||||
let existing_user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE email = $1",
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if existing_user.is_some() {
|
||||
return Err((StatusCode::CONFLICT, "Email already registered".to_string()));
|
||||
}
|
||||
|
||||
// 2. Hash password
|
||||
let password_hash = hash(payload.password, DEFAULT_COST)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Create user
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, username, password_hash) VALUES ($1, $2, $3) RETURNING id, email, password_hash, username, created_at, updated_at",
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.bind(&payload.username)
|
||||
.bind(&password_hash)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 4. Create JWT
|
||||
let token = create_jwt(&user.id.to_string())
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AuthResponse {
|
||||
token,
|
||||
user,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<(StatusCode, Json<AuthResponse>), (StatusCode, String)> {
|
||||
// 1. Find user by email
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE email = $1",
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid email or password".to_string()))?;
|
||||
|
||||
// 2. Verify password
|
||||
let is_valid = verify(payload.password, &user.password_hash)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !is_valid {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid email or password".to_string()));
|
||||
}
|
||||
|
||||
// 3. Create JWT
|
||||
let token = create_jwt(&user.id.to_string())
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(AuthResponse {
|
||||
token,
|
||||
user,
|
||||
}),
|
||||
))
|
||||
}
|
||||
134
server/src/handlers/device_handler.rs
Normal file
134
server/src/handlers/device_handler.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::{Device, HeartbeatResponse, RegisterDeviceRequest};
|
||||
use crate::AppState;
|
||||
|
||||
/// Register a new remote device for the authenticated user
|
||||
pub async fn register_device(
|
||||
AuthUser(claims): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterDeviceRequest>,
|
||||
) -> Result<(StatusCode, Json<Device>), (StatusCode, String)> {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
|
||||
|
||||
let device = sqlx::query_as::<_, Device>(
|
||||
r#"
|
||||
INSERT INTO devices (user_id, name, hostname, ip_address, status)
|
||||
VALUES ($1, $2, $3, $4, 'offline')
|
||||
RETURNING id, user_id, name, hostname, ip_address, status, last_heartbeat, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.hostname)
|
||||
.bind(&payload.ip_address)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(device)))
|
||||
}
|
||||
|
||||
/// List all devices registered by the authenticated user
|
||||
pub async fn list_devices(
|
||||
AuthUser(claims): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Device>>, (StatusCode, String)> {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
|
||||
|
||||
let devices = sqlx::query_as::<_, Device>(
|
||||
r#"
|
||||
SELECT id, user_id, name, hostname, ip_address, status, last_heartbeat, created_at, updated_at
|
||||
FROM devices
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(devices))
|
||||
}
|
||||
|
||||
/// Update last heartbeat and set status to 'online'
|
||||
pub async fn agent_heartbeat(
|
||||
AuthUser(claims): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(device_id): Path<Uuid>,
|
||||
) -> Result<Json<HeartbeatResponse>, (StatusCode, String)> {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE devices
|
||||
SET status = 'online', last_heartbeat = $1, updated_at = $1
|
||||
WHERE id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.bind(device_id)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Device not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(HeartbeatResponse {
|
||||
success: true,
|
||||
status: "online".to_string(),
|
||||
last_heartbeat: now,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete a registered device
|
||||
pub async fn delete_device(
|
||||
AuthUser(claims): AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(device_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM devices
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(device_id)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Device not found or unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
5
server/src/handlers/mod.rs
Normal file
5
server/src/handlers/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth_handler;
|
||||
pub mod user_handler;
|
||||
pub mod device_handler;
|
||||
pub mod todo;
|
||||
|
||||
1
server/src/handlers/todo/mod.rs
Normal file
1
server/src/handlers/todo/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod todo_handler;
|
||||
145
server/src/handlers/todo/todo_handler.rs
Normal file
145
server/src/handlers/todo/todo_handler.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::{CreateTodoRequest, Todo, UpdateTodoRequest};
|
||||
use crate::AppState;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn create_todo(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(claims): AuthUser,
|
||||
Json(payload): Json<CreateTodoRequest>,
|
||||
) -> Result<Json<Todo>, (StatusCode, String)> {
|
||||
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
|
||||
|
||||
let todo = sqlx::query_as::<_, Todo>(
|
||||
"INSERT INTO todos (user_id, title, description) VALUES ($1, $2, $3) RETURNING id, user_id, title, description, is_completed, created_at, updated_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&payload.title)
|
||||
.bind(&payload.description)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(todo))
|
||||
}
|
||||
|
||||
pub async fn list_todos(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(claims): AuthUser,
|
||||
) -> Result<Json<Vec<Todo>>, (StatusCode, String)> {
|
||||
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
|
||||
|
||||
let todos = sqlx::query_as::<_, Todo>(
|
||||
"SELECT id, user_id, title, description, is_completed, created_at, updated_at FROM todos WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(todos))
|
||||
}
|
||||
|
||||
pub async fn update_todo(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(claims): AuthUser,
|
||||
Path(todo_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateTodoRequest>,
|
||||
) -> Result<Json<Todo>, (StatusCode, String)> {
|
||||
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
|
||||
|
||||
// 1. Check if todo exists and belongs to user
|
||||
let _existing_todo = sqlx::query_as::<_, Todo>(
|
||||
"SELECT id, user_id, title, description, is_completed, created_at, updated_at FROM todos WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(todo_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Todo not found".to_string()))?;
|
||||
|
||||
// 2. Update fields dynamically
|
||||
let mut query_builder = sqlx::QueryBuilder::new("UPDATE todos SET ");
|
||||
let mut has_updates = false;
|
||||
|
||||
if let Some(ref title) = payload.title {
|
||||
if has_updates {
|
||||
query_builder.push(", ");
|
||||
}
|
||||
query_builder.push("title = ");
|
||||
query_builder.push_bind(title);
|
||||
has_updates = true;
|
||||
}
|
||||
if let Some(ref description) = payload.description {
|
||||
if has_updates {
|
||||
query_builder.push(", ");
|
||||
}
|
||||
query_builder.push("description = ");
|
||||
query_builder.push_bind(description);
|
||||
has_updates = true;
|
||||
}
|
||||
if let Some(is_completed) = payload.is_completed {
|
||||
if has_updates {
|
||||
query_builder.push(", ");
|
||||
}
|
||||
query_builder.push("is_completed = ");
|
||||
query_builder.push_bind(is_completed);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if !has_updates {
|
||||
return Err((StatusCode::BAD_REQUEST, "No fields to update".to_string()));
|
||||
}
|
||||
|
||||
// Add updated_at to the end of the SET clause
|
||||
query_builder.push(", updated_at = NOW()");
|
||||
|
||||
query_builder.push(" WHERE id = ").push_bind(todo_id).push(" AND user_id = ").push_bind(user_id);
|
||||
|
||||
// DEBUG: Print the generated SQL
|
||||
let sql = query_builder.sql();
|
||||
tracing::info!("Generated SQL: {}", sql);
|
||||
|
||||
query_builder.build().execute(&state.db).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Return updated todo
|
||||
let updated_todo = sqlx::query_as::<_, Todo>(
|
||||
"SELECT id, user_id, title, description, is_completed, created_at, updated_at FROM todos WHERE id = $1",
|
||||
)
|
||||
.bind(todo_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(updated_todo))
|
||||
}
|
||||
|
||||
pub async fn delete_todo(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(claims): AuthUser,
|
||||
Path(todo_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM todos WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(todo_id)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Todo not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
52
server/src/handlers/user_handler.rs
Normal file
52
server/src/handlers/user_handler.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::{UpdateUserRequest, User};
|
||||
use crate::AppState;
|
||||
|
||||
pub async fn get_me(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(claims): AuthUser,
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(claims.sub.parse::<uuid::Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(user))
|
||||
}
|
||||
|
||||
pub async fn update_me(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(claims): AuthUser,
|
||||
Json(payload): Json<UpdateUserRequest>,
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
let user_id = claims.sub.parse::<uuid::Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
|
||||
|
||||
if let Some(new_username) = payload.username {
|
||||
sqlx::query(
|
||||
"UPDATE users SET username = $1, updated_at = NOW() WHERE id = $2",
|
||||
)
|
||||
.bind(&new_username)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
let updated_user = sqlx::query_as::<_, User>(
|
||||
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(updated_user))
|
||||
}
|
||||
75
server/src/main.rs
Normal file
75
server/src/main.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post, patch, delete},
|
||||
Router,
|
||||
};
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
use dotenvy::dotenv;
|
||||
|
||||
mod auth;
|
||||
mod handlers;
|
||||
mod models;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::handlers::auth_handler::{login, register};
|
||||
use crate::handlers::user_handler::{get_me, update_me};
|
||||
use crate::handlers::device_handler::{register_device, list_devices, agent_heartbeat, delete_device};
|
||||
use crate::handlers::todo::todo_handler::{create_todo, delete_todo, list_todos, update_todo};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db: PgPool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
dotenv().ok();
|
||||
|
||||
// Database connection pool
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to Postgres");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run DB migrations");
|
||||
|
||||
let state = AppState { db: pool };
|
||||
|
||||
|
||||
// Build our application with state
|
||||
let app = Router::new()
|
||||
.route("/", get(handler))
|
||||
.route("/auth/register", post(register))
|
||||
.route("/auth/login", post(login))
|
||||
.route("/auth/me", get(me))
|
||||
.route("/users/me", get(get_me).patch(update_me))
|
||||
.route("/devices", post(register_device).get(list_devices))
|
||||
.route("/devices/:id/heartbeat", post(agent_heartbeat))
|
||||
.route("/devices/:id", delete(delete_device))
|
||||
.route("/todos", post(create_todo).get(list_todos))
|
||||
.route("/todos/:id", patch(update_todo).delete(delete_todo))
|
||||
.with_state(state);
|
||||
|
||||
|
||||
// Run it with hyper on localhost:3000
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||
info!("listening on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
async fn handler(State(state): State<AppState>) -> &'static str {
|
||||
let _ = &state.db;
|
||||
"Hello, HELIOS!"
|
||||
}
|
||||
|
||||
async fn me(AuthUser(claims): AuthUser) -> String {
|
||||
format!("Welcome, user: {}", claims.sub)
|
||||
}
|
||||
16
server/src/migrate_test.rs
Normal file
16
server/src/migrate_test.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::env;
|
||||
use dotenvy::dotenv;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv().ok();
|
||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let pool = PgPool::connect(&database_url).await?;
|
||||
|
||||
println!("Running migrations...");
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
println!("Migrations completed successfully!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
90
server/src/models.rs
Normal file
90
server/src/models.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub username: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Todo {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub is_completed: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTodoRequest {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTodoRequest {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_completed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Device {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub hostname: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub status: String,
|
||||
pub last_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterDeviceRequest {
|
||||
pub name: String,
|
||||
pub hostname: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HeartbeatResponse {
|
||||
pub success: bool,
|
||||
pub status: String,
|
||||
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user