setup local dev env
This commit is contained in:
2842
backend/Cargo.lock
generated
Normal file
2842
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,19 @@
|
||||
[package]
|
||||
name = "campaign-manager-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
[workspace]
|
||||
members = ["common", "campaign-service", "content-service"]
|
||||
resolver = "2"
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
|
||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||
dotenvy = "0.15"
|
||||
jsonwebtoken = "9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
argon2 = "0.5"
|
||||
|
||||
18
backend/campaign-service/Cargo.toml
Normal file
18
backend/campaign-service/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "campaign-service"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
common = { path = "../common" }
|
||||
dotenvy = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
14
backend/campaign-service/Dockerfile
Normal file
14
backend/campaign-service/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM rust:alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache build-base pkgconfig openssl-dev
|
||||
|
||||
COPY backend ./backend
|
||||
RUN cargo build --release --manifest-path backend/Cargo.toml -p campaign-service
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates openssl libgcc libstdc++
|
||||
|
||||
COPY --from=builder /app/backend/target/release/campaign-service /usr/local/bin/campaign-service
|
||||
EXPOSE 3000
|
||||
CMD ["campaign-service"]
|
||||
15
backend/campaign-service/migrations/campaign/0001_init.sql
Normal file
15
backend/campaign-service/migrations/campaign/0001_init.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
dm_user_id UUID NOT NULL,
|
||||
ruleset_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
group_id UUID NOT NULL REFERENCES groups(id),
|
||||
user_id UUID NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
15
backend/campaign-service/migrations/users/0001_init.sql
Normal file
15
backend/campaign-service/migrations/users/0001_init.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
118
backend/campaign-service/src/main.rs
Normal file
118
backend/campaign-service/src/main.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use common::{issue_access_token, AppResult};
|
||||
use serde::Serialize;
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
users_pool: PgPool,
|
||||
campaign_pool: PgPool,
|
||||
jwt_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
service: &'static str,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshResponse {
|
||||
#[serde(rename = "accessToken")]
|
||||
access_token: String,
|
||||
#[serde(rename = "refreshToken")]
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "campaign_service=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let users_database_url =
|
||||
std::env::var("USERS_DATABASE_URL").expect("USERS_DATABASE_URL must be set");
|
||||
let campaign_database_url =
|
||||
std::env::var("CAMPAIGN_DATABASE_URL").expect("CAMPAIGN_DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let port: u16 = std::env::var("CAMPAIGN_PORT")
|
||||
.or_else(|_| std::env::var("PORT"))
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.expect("PORT must be a valid u16");
|
||||
|
||||
let users_pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect_lazy(&users_database_url)
|
||||
.expect("invalid USERS_DATABASE_URL");
|
||||
|
||||
let campaign_pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect_lazy(&campaign_database_url)
|
||||
.expect("invalid CAMPAIGN_DATABASE_URL");
|
||||
|
||||
let state = AppState {
|
||||
users_pool,
|
||||
campaign_pool,
|
||||
jwt_secret,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/v1/auth/refresh", post(refresh_token))
|
||||
.route("/v1/auth/login", post(not_implemented))
|
||||
.route("/v1/auth/register", post(not_implemented))
|
||||
.route("/v1/auth/logout", post(not_implemented))
|
||||
.route("/v1/*path", any(not_implemented))
|
||||
.with_state(state)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.expect("failed to bind");
|
||||
|
||||
tracing::info!(
|
||||
"campaign-service listening on {}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let _ = &state.users_pool;
|
||||
let _ = &state.campaign_pool;
|
||||
|
||||
Json(HealthResponse {
|
||||
service: "campaign-service",
|
||||
status: "ok",
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh_token(State(state): State<AppState>) -> AppResult<Json<RefreshResponse>> {
|
||||
// Stub response until real refresh-token/session persistence is implemented.
|
||||
let access_token = issue_access_token(&state.jwt_secret, "dev-user", vec!["user".to_string()])?;
|
||||
let refresh_token = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
Ok(Json(RefreshResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn not_implemented() -> impl IntoResponse {
|
||||
(StatusCode::NOT_IMPLEMENTED, "not implemented")
|
||||
}
|
||||
12
backend/common/Cargo.toml
Normal file
12
backend/common/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
40
backend/common/src/error.rs
Normal file
40
backend/common/src/error.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiErrorBody {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
let body = ApiErrorBody {
|
||||
error: self.to_string(),
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
49
backend/common/src/jwt.rs
Normal file
49
backend/common/src/jwt.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AppError, AppResult};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
pub global_roles: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn issue_access_token(
|
||||
secret: &str,
|
||||
user_id: &str,
|
||||
global_roles: Vec<String>,
|
||||
) -> AppResult<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::minutes(15);
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_owned(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: exp.timestamp() as usize,
|
||||
global_roles,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|err| AppError::Internal(format!("failed to encode JWT: {err}")))
|
||||
}
|
||||
|
||||
pub fn decode_jwt(secret: &str, token: &str) -> AppResult<Claims> {
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
|
||||
decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|err| AppError::Unauthorized(format!("invalid token: {err}")))
|
||||
}
|
||||
5
backend/common/src/lib.rs
Normal file
5
backend/common/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod jwt;
|
||||
|
||||
pub use error::{ApiErrorBody, AppError, AppResult};
|
||||
pub use jwt::{decode_jwt, issue_access_token, Claims};
|
||||
16
backend/content-service/Cargo.toml
Normal file
16
backend/content-service/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "content-service"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
common = { path = "../common" }
|
||||
dotenvy = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
14
backend/content-service/Dockerfile
Normal file
14
backend/content-service/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM rust:alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache build-base pkgconfig openssl-dev
|
||||
|
||||
COPY backend ./backend
|
||||
RUN cargo build --release --manifest-path backend/Cargo.toml -p content-service
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache ca-certificates openssl libgcc libstdc++
|
||||
|
||||
COPY --from=builder /app/backend/target/release/content-service /usr/local/bin/content-service
|
||||
EXPOSE 3001
|
||||
CMD ["content-service"]
|
||||
7
backend/content-service/migrations/content/0001_init.sql
Normal file
7
backend/content-service/migrations/content/0001_init.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS rulesets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
124
backend/content-service/src/main.rs
Normal file
124
backend/content-service/src/main.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{any, get},
|
||||
Json, Router,
|
||||
};
|
||||
use common::{decode_jwt, AppError, AppResult};
|
||||
use serde::Serialize;
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
content_pool: PgPool,
|
||||
jwt_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
service: &'static str,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RulesetSummary {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "content_service=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let content_database_url =
|
||||
std::env::var("CONTENT_DATABASE_URL").expect("CONTENT_DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let port: u16 = std::env::var("CONTENT_PORT")
|
||||
.or_else(|_| std::env::var("PORT"))
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()
|
||||
.expect("PORT must be a valid u16");
|
||||
|
||||
let content_pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect_lazy(&content_database_url)
|
||||
.expect("invalid CONTENT_DATABASE_URL");
|
||||
|
||||
let state = AppState {
|
||||
content_pool,
|
||||
jwt_secret,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/v1/rulesets", get(list_rulesets))
|
||||
.route("/v1/*path", any(not_implemented))
|
||||
.with_state(state)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.expect("failed to bind");
|
||||
|
||||
tracing::info!(
|
||||
"content-service listening on {}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let _ = &state.content_pool;
|
||||
|
||||
Json(HealthResponse {
|
||||
service: "content-service",
|
||||
status: "ok",
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_rulesets(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Vec<RulesetSummary>>> {
|
||||
require_bearer(&state.jwt_secret, &headers)?;
|
||||
|
||||
Ok(Json(vec![
|
||||
RulesetSummary {
|
||||
id: "generic",
|
||||
name: "Generic",
|
||||
},
|
||||
RulesetSummary {
|
||||
id: "dsa5e",
|
||||
name: "DSA5e",
|
||||
},
|
||||
]))
|
||||
}
|
||||
|
||||
fn require_bearer(jwt_secret: &str, headers: &HeaderMap) -> AppResult<()> {
|
||||
let header = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| AppError::Unauthorized("missing Authorization header".to_string()))?;
|
||||
|
||||
let token = header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or_else(|| AppError::Unauthorized("expected Bearer token".to_string()))?;
|
||||
|
||||
let _claims = decode_jwt(jwt_secret, token)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn not_implemented() -> impl IntoResponse {
|
||||
(StatusCode::NOT_IMPLEMENTED, "not implemented")
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// Module entrypoint — reserved for integration tests and shared types.
|
||||
@@ -1,43 +0,0 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::any,
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env if present (dev convenience)
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "campaign_manager_backend=debug,tower_http=debug".into()))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let _database_url = std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must be set");
|
||||
let _jwt_secret = std::env::var("JWT_SECRET")
|
||||
.expect("JWT_SECRET must be set");
|
||||
|
||||
// TODO: initialize sqlx connection pool
|
||||
// let pool = sqlx::PgPool::connect(&database_url).await.expect("failed to connect to DB");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/v1/*path", any(not_implemented))
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||
.await
|
||||
.expect("failed to bind");
|
||||
|
||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
}
|
||||
|
||||
async fn not_implemented() -> impl IntoResponse {
|
||||
(StatusCode::NOT_IMPLEMENTED, "not implemented")
|
||||
}
|
||||
Reference in New Issue
Block a user