2026-01-27 16:21:58 +00:00
|
|
|
mod api;
|
|
|
|
|
mod auth;
|
|
|
|
|
mod config;
|
|
|
|
|
mod db;
|
|
|
|
|
mod demo;
|
|
|
|
|
mod models;
|
|
|
|
|
mod plugins;
|
|
|
|
|
mod voting;
|
|
|
|
|
|
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
use std::sync::Arc;
|
2026-02-01 13:26:56 +00:00
|
|
|
use axum::{middleware, Extension};
|
|
|
|
|
use axum::http::{HeaderName, HeaderValue};
|
|
|
|
|
use axum::response::Response;
|
2026-01-27 16:21:58 +00:00
|
|
|
use chrono::{Datelike, Timelike, Utc, Weekday};
|
|
|
|
|
use serde_json::json;
|
2026-01-28 23:46:43 +00:00
|
|
|
use thiserror::Error;
|
2026-01-27 16:21:58 +00:00
|
|
|
use tower_http::cors::{Any, CorsLayer};
|
|
|
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use crate::config::Config;
|
|
|
|
|
use crate::plugins::HookContext;
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
#[derive(Debug, Error)]
|
|
|
|
|
enum StartupError {
|
|
|
|
|
#[error("Failed to load configuration: {0}")]
|
|
|
|
|
Config(#[from] envy::Error),
|
|
|
|
|
#[error("JWT_SECRET must be set")]
|
|
|
|
|
MissingJwtSecret,
|
|
|
|
|
#[error("Failed to create database pool: {0}")]
|
|
|
|
|
Db(#[from] sqlx::Error),
|
|
|
|
|
#[error("Failed to run database migrations: {0}")]
|
|
|
|
|
Migrations(#[from] sqlx::migrate::MigrateError),
|
|
|
|
|
#[error("Failed to initialize plugins: {0}")]
|
|
|
|
|
Plugins(#[from] crate::plugins::PluginError),
|
|
|
|
|
#[error("Failed to bind server listener: {0}")]
|
|
|
|
|
Bind(#[from] std::io::Error),
|
|
|
|
|
#[error("Server error: {0}")]
|
|
|
|
|
Serve(String),
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 16:21:58 +00:00
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() {
|
|
|
|
|
tracing_subscriber::fmt::init();
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
if let Err(e) = run().await {
|
|
|
|
|
tracing::error!("{e}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn run() -> Result<(), StartupError> {
|
2026-01-27 16:21:58 +00:00
|
|
|
dotenvy::dotenv().ok();
|
|
|
|
|
|
|
|
|
|
// Load configuration
|
2026-01-28 23:46:43 +00:00
|
|
|
let config = Arc::new(Config::from_env()?);
|
|
|
|
|
|
|
|
|
|
if config.jwt_secret.trim().is_empty() {
|
|
|
|
|
return Err(StartupError::MissingJwtSecret);
|
|
|
|
|
}
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
if config.is_demo() {
|
|
|
|
|
tracing::info!("🎭 DEMO MODE ENABLED - Some actions are restricted");
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone());
|
2026-01-27 16:21:58 +00:00
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
let pool = db::create_pool(&database_url).await?;
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
tracing::info!("Connected to database");
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
let mut migrator = sqlx::migrate!("./migrations");
|
|
|
|
|
if config.is_demo() {
|
|
|
|
|
migrator.set_ignore_missing(true);
|
|
|
|
|
}
|
|
|
|
|
migrator.run(&pool).await?;
|
|
|
|
|
|
|
|
|
|
if config.is_demo() {
|
|
|
|
|
let mut demo_migrator = sqlx::migrate!("./migrations_demo");
|
|
|
|
|
demo_migrator.set_ignore_missing(true);
|
|
|
|
|
demo_migrator.run(&pool).await?;
|
|
|
|
|
}
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
let cors = CorsLayer::new()
|
|
|
|
|
.allow_origin(Any)
|
|
|
|
|
.allow_methods(Any)
|
|
|
|
|
.allow_headers(Any);
|
|
|
|
|
|
|
|
|
|
let plugins = plugins::PluginManager::new(pool.clone())
|
|
|
|
|
.register_builtin_plugins()
|
|
|
|
|
.initialize()
|
2026-01-28 23:46:43 +00:00
|
|
|
.await?;
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
{
|
|
|
|
|
let cron_plugins = plugins.clone();
|
|
|
|
|
let cron_pool = pool.clone();
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let mut last_minute_key: i64 = -1;
|
|
|
|
|
let mut last_hour_key: i64 = -1;
|
|
|
|
|
let mut last_day_key: i64 = -1;
|
|
|
|
|
let mut last_week_key: i64 = -1;
|
|
|
|
|
let mut last_15min_key: i64 = -1;
|
|
|
|
|
|
|
|
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
|
|
|
|
loop {
|
|
|
|
|
interval.tick().await;
|
|
|
|
|
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
let minute_key = now.timestamp() / 60;
|
|
|
|
|
if minute_key == last_minute_key {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
last_minute_key = minute_key;
|
|
|
|
|
|
|
|
|
|
let ctx = HookContext {
|
|
|
|
|
pool: cron_pool.clone(),
|
|
|
|
|
community_id: None,
|
|
|
|
|
actor_user_id: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let payload = json!({"ts": now.to_rfc3339()});
|
|
|
|
|
cron_plugins.do_action("cron.minute", ctx.clone(), payload.clone()).await;
|
|
|
|
|
cron_plugins
|
|
|
|
|
.do_action("cron.minutely", ctx.clone(), payload.clone())
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
let min15_key = now.timestamp() / 900;
|
|
|
|
|
if min15_key != last_15min_key {
|
|
|
|
|
last_15min_key = min15_key;
|
|
|
|
|
cron_plugins
|
|
|
|
|
.do_action("cron.every_15_minutes", ctx.clone(), payload.clone())
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let hour_key = now.timestamp() / 3600;
|
|
|
|
|
if hour_key != last_hour_key {
|
|
|
|
|
last_hour_key = hour_key;
|
|
|
|
|
if now.minute() == 0 {
|
|
|
|
|
cron_plugins
|
|
|
|
|
.do_action("cron.hourly", ctx.clone(), payload.clone())
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let day_key = now.timestamp() / 86_400;
|
|
|
|
|
if day_key != last_day_key {
|
|
|
|
|
last_day_key = day_key;
|
|
|
|
|
if now.hour() == 0 && now.minute() == 0 {
|
|
|
|
|
cron_plugins
|
|
|
|
|
.do_action("cron.daily", ctx.clone(), payload.clone())
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let iso_week = now.iso_week();
|
|
|
|
|
let week_key = (iso_week.year() as i64) * 100 + (iso_week.week() as i64);
|
|
|
|
|
if week_key != last_week_key {
|
|
|
|
|
last_week_key = week_key;
|
|
|
|
|
if now.weekday() == Weekday::Mon && now.hour() == 0 && now.minute() == 0 {
|
|
|
|
|
cron_plugins
|
|
|
|
|
.do_action("cron.weekly", ctx.clone(), payload.clone())
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WASM plugins need per-community context.
|
|
|
|
|
let community_ids: Vec<Uuid> = match sqlx::query_scalar(
|
|
|
|
|
"SELECT id FROM communities WHERE is_active = true",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&cron_pool)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(ids) => ids,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("cron: failed to list communities: {}", e);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"];
|
|
|
|
|
if min15_key == last_15min_key {
|
|
|
|
|
wasm_hooks.push("cron.every_15_minutes");
|
|
|
|
|
}
|
|
|
|
|
if now.minute() == 0 {
|
|
|
|
|
wasm_hooks.push("cron.hourly");
|
|
|
|
|
}
|
|
|
|
|
if now.hour() == 0 && now.minute() == 0 {
|
|
|
|
|
wasm_hooks.push("cron.daily");
|
|
|
|
|
if now.weekday() == Weekday::Mon {
|
|
|
|
|
wasm_hooks.push("cron.weekly");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for cid in community_ids {
|
|
|
|
|
for hook in &wasm_hooks {
|
|
|
|
|
cron_plugins
|
|
|
|
|
.do_wasm_action_for_community(hook, cid, payload.clone())
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let app = api::create_router(pool.clone(), config.clone())
|
|
|
|
|
.layer(Extension(plugins))
|
|
|
|
|
.layer(Extension(config.clone()))
|
|
|
|
|
.layer(cors)
|
2026-02-01 13:26:56 +00:00
|
|
|
.layer(TraceLayer::new_for_http())
|
|
|
|
|
.layer(middleware::map_response(add_security_headers));
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
let host: std::net::IpAddr = config.server_host.parse()
|
|
|
|
|
.unwrap_or_else(|_| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
|
|
|
|
|
let addr = SocketAddr::from((host, config.server_port));
|
|
|
|
|
tracing::info!("Likwid backend listening on http://{}", addr);
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
|
|
|
|
axum::serve(listener, app)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| StartupError::Serve(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
2026-02-01 13:26:56 +00:00
|
|
|
|
|
|
|
|
async fn add_security_headers(mut res: Response) -> Response {
|
|
|
|
|
let headers = res.headers_mut();
|
|
|
|
|
|
|
|
|
|
if !headers.contains_key("x-content-type-options") {
|
|
|
|
|
headers.insert(
|
|
|
|
|
HeaderName::from_static("x-content-type-options"),
|
|
|
|
|
HeaderValue::from_static("nosniff"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !headers.contains_key("x-frame-options") {
|
|
|
|
|
headers.insert(
|
|
|
|
|
HeaderName::from_static("x-frame-options"),
|
|
|
|
|
HeaderValue::from_static("DENY"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !headers.contains_key("referrer-policy") {
|
|
|
|
|
headers.insert(
|
|
|
|
|
HeaderName::from_static("referrer-policy"),
|
|
|
|
|
HeaderValue::from_static("no-referrer"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res
|
|
|
|
|
}
|