Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25cb697
feat: update environment configuration and add engineering standards …
nxdun Apr 26, 2026
8bca0b0
feat: add data models for contributions, health, validation, and ytdlp
nxdun Apr 26, 2026
4b3f427
feat: enhance configuration loading with error handling and add upstr…
nxdun Apr 26, 2026
0e54f1e
fix: update model imports in controllers for consistency
nxdun Apr 26, 2026
1a40c84
refactor: standardize API key and CAPTCHA token handling across middl…
nxdun Apr 26, 2026
2924513
feat: enhance rate limiting logic with improved error handling and do…
nxdun Apr 26, 2026
74b7a11
refactor: update module structure and rename contributions router fun…
nxdun Apr 26, 2026
799617d
refactor: update contributions router function for consistency and im…
nxdun Apr 26, 2026
4771801
refactor: reorganize contributions module structure and improve cachi…
nxdun Apr 26, 2026
1b3234f
refactor: update model imports and improve AppConfig initialization i…
nxdun Apr 26, 2026
7a75f1d
refactor: update cache pruning interval from 600 seconds to 10 minutes
nxdun Apr 26, 2026
67ba737
refactor: update environment variable formatting and enhance error ha…
nxdun Apr 26, 2026
5c23a9f
refactor: optimize job response construction and enhance CORS matcher…
nxdun Apr 26, 2026
2014414
refactor: improve contributions service initialization and enhance va…
nxdun Apr 26, 2026
952b0c1
refactor: standardize environment variable formatting and enhance err…
nxdun Apr 27, 2026
45e38e5
refactor: enhance documentation and suppress clippy warnings in tests
nxdun Apr 27, 2026
bd01f7e
refactor: replace hardcoded API key string with constant in unit tests
nxdun Apr 27, 2026
a820173
refactor: reorder middleware imports for improved readability
nxdun Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 38 additions & 31 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,41 +1,48 @@
### -------------------------
### Application Runtime / Local
### -------------------------
APP_NAME = "REPLACE_WITH_APP_NAME"
APP_HOST = "REPLACE_WITH_APP_HOST"
APP_PORT = "REPLACE_WITH_APP_PORT"
APP_ENV = "REPLACE_WITH_APP_ENV"
ALLOWED_ORIGINS = "REPLACE_WITH_ALLOWED_ORIGINS"
RUST_LOG = "REPLACE_WITH_RUST_LOG"
YTDLP_PATH = "REPLACE_WITH_YTDLP_PATH"
YTDLP_OUTPUT_DIR = "REPLACE_WITH_YTDLP_OUTPUT_DIR"
YTDLP_TIMEOUT_SECS = "REPLACE_WITH_YTDLP_TIMEOUT_SECS"
YTDLP_EXTERNAL_DOWNLOADER = "REPLACE_WITH_YTDLP_EXTERNAL_DOWNLOADER"
YTDLP_EXTERNAL_DOWNLOADER_ARGS = "REPLACE_WITH_YTDLP_EXTERNAL_DOWNLOADER_ARGS"
CAPTCHA_SECRET_KEY = "REPLACE_WITH_CAPTCHA_SECRET_KEY"
MAX_CONCURRENT_DOWNLOADS = "REPLACE_WITH_MAX_CONCURRENT_DOWNLOADS"
APP_NAME=REPLACE_WITH_APP_NAME
APP_HOST=REPLACE_WITH_APP_HOST
APP_PORT=REPLACE_WITH_APP_PORT
APP_ENV=REPLACE_WITH_APP_ENV
ALLOWED_ORIGINS=REPLACE_WITH_ALLOWED_ORIGINS
RUST_LOG=REPLACE_WITH_RUST_LOG
DOWNLOAD_DIR=REPLACE_WITH_DOWNLOAD_DIR
YTDLP_PATH=REPLACE_WITH_YTDLP_PATH
YTDLP_EXTERNAL_DOWNLOADER=REPLACE_WITH_YTDLP_EXTERNAL_DOWNLOADER
YTDLP_EXTERNAL_DOWNLOADER_ARGS=REPLACE_WITH_YTDLP_EXTERNAL_DOWNLOADER_ARGS
CAPTCHA_SECRET_KEY=REPLACE_WITH_CAPTCHA_SECRET_KEY
MAX_CONCURRENT_DOWNLOADS=REPLACE_WITH_MAX_CONCURRENT_DOWNLOADS
MASTER_API_KEY=REPLACE_WITH_MASTER_API_KEY
GITHUB_PAT=REPLACE_WITH_GITHUB_PAT
GITHUB_USERNAME=REPLACE_WITH_GITHUB_USERNAME
GITHUB_GRAPHQL_URL=https://api.github.com/graphql
WARP_LICENSE_KEY=REPLACE_WITH_WARP_LICENSE_KEY
Comment thread
nxdun marked this conversation as resolved.

### -------------------------
### Terraform / Infra - If you need to deploy
### -------------------------
AWS_ACCESS_KEY_ID = "REPLACE_WITH_AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY = "REPLACE_WITH_AWS_SECRET_ACCESS_KEY"
AWS_ENDPOINT_URL_S3 = "https://REPLACE_WITH_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
AWS_ACCESS_KEY_ID=REPLACE_WITH_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_AWS_SECRET_ACCESS_KEY
AWS_ENDPOINT_URL_S3=https://REPLACE_WITH_R2_ACCOUNT_ID.r2.cloudflarestorage.com
AWS_S3_BUCKET_NAME=REPLACE_WITH_AWS_S3_BUCKET_NAME
# Terraform provider secrets
TF_VAR_DO_TOKEN = "REPLACE_WITH_DIGITALOCEAN_TOKEN"
TF_VAR_CLOUDFLARE_API_TOKEN = "REPLACE_WITH_CLOUDFLARE_API_TOKEN"
TF_VAR_DO_TOKEN=REPLACE_WITH_DIGITALOCEAN_TOKEN
TF_VAR_CLOUDFLARE_API_TOKEN=REPLACE_WITH_CLOUDFLARE_API_TOKEN
# Production DOCKER registry credentials (GHCR)
TF_VAR_GHCR_PAT = "REPLACE_WITH_GHCR_PAT"
TF_VAR_GITHUB_USERNAME = "REPLACE_WITH_GITHUB_USERNAME"
TF_VAR_GHCR_PAT=REPLACE_WITH_GHCR_PAT
TF_VAR_GITHUB_USERNAME=REPLACE_WITH_GITHUB_USERNAME
# ENV values to pass to Production
TF_VAR_APP_ENV = "REPLACE_WITH_TF_VAR_APP_ENV"
TF_VAR_CAPTCHA_SECRET_KEY = "REPLACE_WITH_TF_VAR_CAPTCHA_SECRET_KEY"
TF_VAR_APP_PORT = "REPLACE_WITH_TF_VAR_APP_PORT"
TF_VAR_HOST_PORT = "REPLACE_WITH_TF_VAR_HOST_PORT"
TF_VAR_APP_NAME = "REPLACE_WITH_TF_VAR_APP_NAME"
TF_VAR_ALLOWED_ORIGINS = "REPLACE_WITH_TF_VAR_ALLOWED_ORIGINS"
TF_VAR_RUST_LOG = "REPLACE_WITH_TF_VAR_RUST_LOG"
TF_VAR_MAX_CONCURRENT_DOWNLOADS = "REPLACE_WITH_TF_VAR_MAX_CONCURRENT_DOWNLOADS"
TF_VAR_APP_HOST = "REPLACE_WITH_TF_VAR_APP_HOST"
TF_VAR_YTDLP_PATH = "REPLACE_WITH_TF_VAR_YTDLP_PATH"
TF_VAR_YTDLP_OUTPUT_DIR = "REPLACE_WITH_TF_VAR_YTDLP_OUTPUT_DIR"
TF_VAR_APP_ENV=REPLACE_WITH_TF_VAR_APP_ENV
TF_VAR_CAPTCHA_SECRET_KEY=REPLACE_WITH_TF_VAR_CAPTCHA_SECRET_KEY
TF_VAR_APP_PORT=REPLACE_WITH_TF_VAR_APP_PORT
TF_VAR_HOST_PORT=REPLACE_WITH_TF_VAR_HOST_PORT
TF_VAR_APP_NAME=REPLACE_WITH_TF_VAR_APP_NAME
TF_VAR_ALLOWED_ORIGINS=REPLACE_WITH_TF_VAR_ALLOWED_ORIGINS
TF_VAR_RUST_LOG=REPLACE_WITH_TF_VAR_RUST_LOG
TF_VAR_MAX_CONCURRENT_DOWNLOADS=REPLACE_WITH_TF_VAR_MAX_CONCURRENT_DOWNLOADS
TF_VAR_APP_HOST=REPLACE_WITH_TF_VAR_APP_HOST
TF_VAR_YTDLP_PATH=REPLACE_WITH_TF_VAR_YTDLP_PATH
TF_VAR_DOWNLOAD_DIR=REPLACE_WITH_TF_VAR_DOWNLOAD_DIR
TF_VAR_MASTER_API_KEY=REPLACE_WITH_TF_VAR_MASTER_API_KEY
TF_VAR_WARP_LICENSE_KEY=REPLACE_WITH_TF_VAR_WARP_LICENSE_KEY
70 changes: 70 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Nadzu Backend - Engineering Standards & Policies

This document serves as the foundational mandate for all engineering work on this codebase. It applies to both human developers and AI agents. Strict adherence is required to maintain the "Anti-Corruption Layer" and high-performance nature of the system.

## 1. Architectural Integrity

### DTO vs. Domain Model Separation
* **External DTOs (`*_dto.rs`)**: Strictly for mapping external API responses (e.g., GitHub, YouTube). They must mirror the external schema (e.g., `camelCase`).
* **Domain Models (`src/models/`)**: Clean, optimized structures used by our business logic and returned to our frontend.
* **Anti-Corruption Layer**: Every service must implement a transformation pass (e.g., `transform_calendar`) to convert "dirty" DTOs into "pure" Domain Models. **Never leak external API structures into the rest of the application.**

### Service Layer Responsibility
* Services must handle business logic, caching, and external communication.
* Controllers must only handle request extraction, calling services, and mapping results to HTTP responses.

## 2. Performance & Memory Safety

### Zero-Allocation Strategy
* Use `std::borrow::Cow<'static, str>` for static metadata (colors, labels, constant status messages).
* Avoid `String::clone()` or `.to_string()` inside loops.
* Pre-allocate vector capacities when the size is known or estimable (e.g., `Vec::with_capacity(365)`).

### Iteration Optimization
* Perform data transformations in a **single pass**.
* Calculate metadata (min/max values, counts) during the primary loop to leverage CPU cache and minimize cycles.

## 3. Security Standards

### Constant-Time Validation
* Sensitive comparisons (API keys, tokens) must use `constant_time_eq` to prevent timing attacks.
* Validation logic should be centralized in `AppConfig`.

### Information Hiding
* Internal state (file paths, format flags, system IDs) must **never** be exposed in API responses.
* Use specific "Response" versions of models (e.g., `YtdlpJobResponse`) to filter sensitive fields.

## 4. Configuration Management

### Result-Based Loading
* `AppConfig::from_env()` must return a `Result<Self, ConfigError>`.
* Avoid `std::process::exit` or `panic!` deep in the logic; handle startup failures gracefully in `app.rs`.

### Immutable State
* Config fields should be private where appropriate, using constructors and public methods (`check_api_key`) to enforce security policies.

## 5. Error Handling & API Contract

### Typed Errors
* Use the `AppError` enum for all internal failures.
* Map domain errors to correct HTTP status codes:
* Validation Error $\rightarrow$ `422 Unprocessable Entity`
* Upstream/External Failure $\rightarrow$ `502 Bad Gateway`
* Auth Failure $\rightarrow$ `401 Unauthorized`

### Consistency
* Responses should be flat and idiomatic where possible (avoiding unnecessary "job" or "data" wrappers unless required by the specific API design).

## 6. Development Workflow

### Tooling
* **Clippy**: Must be zero-warning.
* **Rustfmt**: Must be applied to every file.
* **Makefile**: Use `make c` for a full validation suite before concluding any task.

### Documentation
* All public-facing methods and services must have `///` (Rustdoc) comments explaining intent and behavior.
* Complex logic (like the Midnight Snap caching strategy) must be documented inline.

---
*Follow these rules to ensure the codebase remains scalable, secure, and blazingly fast.*
11 changes: 8 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ pub async fn run() {
.on_response(DefaultOnResponse::new().level(Level::INFO));

// 4. Load application config and build shared app state
let config = Arc::new(AppConfig::from_env());
let config = match AppConfig::from_env() {
Ok(cfg) => Arc::new(cfg),
Err(err) => {
error!("Failed to load configuration: {err}");
std::process::exit(1);
}
};
Comment thread
nxdun marked this conversation as resolved.
let ytdlp_manager = Arc::new(YtdlpManager::new(config.clone()));
let rate_limiters = Arc::new(RateLimiters::new());
log_rate_limit_mode(&config);
Expand All @@ -56,7 +62,7 @@ pub async fn run() {
let contributions_service =
Arc::new(crate::services::contributions::ContributionsService::new(
http_client.clone(),
config.github_pat.clone().unwrap_or_default(),
config.github_pat().unwrap_or_default().to_string(),
config
.github_username
.clone()
Expand Down Expand Up @@ -117,6 +123,5 @@ async fn shutdown_signal() {
if let Err(err) = tokio::signal::ctrl_c().await {
error!("failed to listen for CTRL+C: {err}");
}
println!();
info!("Initiating graceful shutdown");
}
158 changes: 133 additions & 25 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
use std::env;
use std::{env, fmt};
use thiserror::Error;

use crate::middleware::constant_time_eq;

#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Missing required environment variable: {0}")]
MissingVar(String),
#[error("Invalid value for {key}: {details}")]
InvalidValue { key: String, details: String },
}

/// Helper: Fetches an env var, applies a default, and parses to the required type.
fn env_or<T: std::str::FromStr>(key: &str, default: &str) -> T {
Expand All @@ -16,6 +27,15 @@ fn env_or<T: std::str::FromStr>(key: &str, default: &str) -> T {
})
}

/// Helper: Fetches and parses an environment variable, returning `ConfigError::InvalidValue` on parse failure.
fn env_parse<T: std::str::FromStr>(key: &str, default: &str) -> Result<T, ConfigError> {
let val = env::var(key).unwrap_or_else(|_| default.to_string());
val.parse::<T>().map_err(|_| ConfigError::InvalidValue {
key: key.to_string(),
details: format!("'{}' is not a valid {}", val, std::any::type_name::<T>()),
})
}

/// Helper: Fetches an optional env var and trims it, returning None if empty.
fn env_opt(key: &str) -> Option<String> {
env::var(key)
Expand All @@ -24,7 +44,8 @@ fn env_opt(key: &str) -> Option<String> {
.filter(|v| !v.is_empty())
}

#[derive(Debug, Clone)]
/// Application configuration loaded from environment variables.
#[derive(Clone)]
pub struct AppConfig {
pub name: String,
pub env: String,
Expand All @@ -36,40 +57,127 @@ pub struct AppConfig {
pub ytdlp_external_downloader: Option<String>,
pub ytdlp_external_downloader_args: Option<String>,
pub max_concurrent_downloads: usize,
pub captcha_secret_key: Option<String>,
pub master_api_key: String,
pub github_pat: Option<String>,
captcha_secret_key: Option<String>,
master_api_key: String,
github_pat: Option<String>,
pub github_username: Option<String>,
pub github_graphql_url: String,
}

impl fmt::Debug for AppConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AppConfig")
.field("name", &self.name)
.field("env", &self.env)
.field("host", &self.host)
.field("port", &self.port)
.field("allowed_origins", &self.allowed_origins)
.field("download_dir", &self.download_dir)
.field("ytdlp_path", &self.ytdlp_path)
.field("ytdlp_external_downloader", &self.ytdlp_external_downloader)
.field(
"ytdlp_external_downloader_args",
&self.ytdlp_external_downloader_args,
)
.field("max_concurrent_downloads", &self.max_concurrent_downloads)
.field("captcha_secret_key", &"***")
.field("master_api_key", &"***")
.field("github_pat", &"***")
.field("github_username", &self.github_username)
.field("github_graphql_url", &self.github_graphql_url)
.finish()
}
}

impl AppConfig {
/// Loads the application configuration from environment variables.
pub fn from_env() -> Self {
/// Internal constructor for creating config instances.
#[allow(clippy::too_many_arguments)]
pub const fn new(
name: String,
env: String,
host: String,
port: u16,
allowed_origins: Option<String>,
download_dir: String,
ytdlp_path: String,
ytdlp_external_downloader: Option<String>,
ytdlp_external_downloader_args: Option<String>,
max_concurrent_downloads: usize,
captcha_secret_key: Option<String>,
master_api_key: String,
github_pat: Option<String>,
github_username: Option<String>,
github_graphql_url: String,
) -> Self {
Self {
name: env_or("APP_NAME", "nadzu-backend"),
env: env_or("APP_ENV", "production"),
host: env_or("APP_HOST", "127.0.0.1"),
port: env_or("APP_PORT", "8080"),
allowed_origins: env_opt("ALLOWED_ORIGINS"),
download_dir: env_or("DOWNLOAD_DIR", "downloads"),
ytdlp_path: env_or("YTDLP_PATH", "yt-dlp"),
ytdlp_external_downloader: env_opt("YTDLP_EXTERNAL_DOWNLOADER"),
ytdlp_external_downloader_args: env_opt("YTDLP_EXTERNAL_DOWNLOADER_ARGS"),
max_concurrent_downloads: env_or("MAX_CONCURRENT_DOWNLOADS", "3"),
captcha_secret_key: env_opt("CAPTCHA_SECRET_KEY"),
master_api_key: env_opt("MASTER_API_KEY").unwrap_or_else(|| {
tracing::error!("MASTER_API_KEY must be set to a non-empty value");
std::process::exit(1)
}),
github_pat: env_opt("GITHUB_PAT"),
github_username: env_opt("GITHUB_USERNAME"),
github_graphql_url: env_or("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql"),
name,
env,
host,
port,
allowed_origins,
download_dir,
ytdlp_path,
ytdlp_external_downloader,
ytdlp_external_downloader_args,
max_concurrent_downloads,
captcha_secret_key,
master_api_key,
github_pat,
github_username,
github_graphql_url,
}
}

/// Loads the application configuration from environment variables.
pub fn from_env() -> Result<Self, ConfigError> {
let master_api_key = env_opt("MASTER_API_KEY")
.ok_or_else(|| ConfigError::MissingVar("MASTER_API_KEY".to_string()))?;

Ok(Self::new(
env_or("APP_NAME", "nadzu-backend"),
env_or("APP_ENV", "production"),
env_or("APP_HOST", "127.0.0.1"),
env_parse("APP_PORT", "8080")?,
env_opt("ALLOWED_ORIGINS"),
env_or("DOWNLOAD_DIR", "downloads"),
env_or("YTDLP_PATH", "yt-dlp"),
env_opt("YTDLP_EXTERNAL_DOWNLOADER"),
env_opt("YTDLP_EXTERNAL_DOWNLOADER_ARGS"),
env_parse("MAX_CONCURRENT_DOWNLOADS", "3")?,
env_opt("CAPTCHA_SECRET_KEY"),
master_api_key,
env_opt("GITHUB_PAT"),
env_opt("GITHUB_USERNAME"),
env_or("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql"),
))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Securely checks if the provided key matches the master API key using constant-time comparison.
#[must_use]
pub fn check_api_key(&self, provided_key: &str) -> bool {
constant_time_eq(provided_key, &self.master_api_key)
}

/// Returns the GitHub Personal Access Token if configured.
pub fn github_pat(&self) -> Option<&str> {
self.github_pat.as_deref()
}

/// Returns the CAPTCHA secret key if configured.
pub fn captcha_secret_key(&self) -> Option<&str> {
self.captcha_secret_key.as_deref()
}

/// Returns the full address string for the server.
pub fn addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}

/// Helper for testing to inject a master API key.
#[cfg(test)]
#[must_use]
pub fn with_master_key(mut self, key: &str) -> Self {
self.master_api_key = key.to_string();
self
}
}
5 changes: 4 additions & 1 deletion src/controllers/api/v1/contributions_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ use axum::{
};
use serde::Deserialize;

use crate::{error::AppError, models::contributions_model::ContributionsResponse, state::AppState};
use crate::{error::AppError, models::contributions::ContributionsResponse, state::AppState};

/// Request query parameters for the contributions endpoint.
#[derive(Debug, Deserialize)]
pub struct ContributionsQuery {
pub username: Option<String>,
}

/// Retrieves GitHub contributions for a specific user.
/// Defaults to the configured system user if none is provided.
pub async fn get_contributions(
State(state): State<AppState>,
Query(query): Query<ContributionsQuery>,
Expand Down
Loading
Loading