mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
Compare commits
7 commits
aa2e7894b4
...
33311c51c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 33311c51c8 | |||
| 070257597e | |||
| 8c794432a3 | |||
| 30c6073126 | |||
| 51a78b1eb4 | |||
| d381478b29 | |||
| 4b4e2458e4 |
18 changed files with 588 additions and 62 deletions
|
|
@ -8,6 +8,15 @@ DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid
|
||||||
SERVER_HOST=127.0.0.1
|
SERVER_HOST=127.0.0.1
|
||||||
SERVER_PORT=3000
|
SERVER_PORT=3000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CORS
|
||||||
|
# =============================================================================
|
||||||
|
# In development, CORS defaults to allowing any origin.
|
||||||
|
# In production, set an allowlist (comma-separated) to restrict browser access.
|
||||||
|
# Example:
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# JWT Secret for authentication tokens
|
# JWT Secret for authentication tokens
|
||||||
# IMPORTANT: Change this in production!
|
# IMPORTANT: Change this in production!
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -34,10 +35,14 @@ pub fn create_token(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.leeway = 30;
|
||||||
|
validation.required_spec_claims = HashSet::from(["exp".to_string(), "sub".to_string()]);
|
||||||
|
|
||||||
let token_data = decode::<Claims>(
|
let token_data = decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
&DecodingKey::from_secret(secret.as_bytes()),
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
&Validation::default(),
|
&validation,
|
||||||
)?;
|
)?;
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
|
http::header,
|
||||||
http::{request::Parts, StatusCode},
|
http::{request::Parts, StatusCode},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -8,6 +9,7 @@ use uuid::Uuid;
|
||||||
use super::jwt::{verify_token, Claims};
|
use super::jwt::{verify_token, Claims};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
@ -22,23 +24,39 @@ where
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let auth_header = parts
|
let auth_header = parts
|
||||||
.headers
|
.headers
|
||||||
.get("Authorization")
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
|
||||||
|
|
||||||
let token = auth_header.strip_prefix("Bearer ").ok_or((
|
let mut pieces = auth_header.split_whitespace();
|
||||||
|
let scheme = pieces.next().ok_or((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"Invalid authorization header format",
|
"Invalid authorization header format",
|
||||||
))?;
|
))?;
|
||||||
|
let token = pieces.next().ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid authorization header format",
|
||||||
|
))?;
|
||||||
|
if pieces.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid authorization header format",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let secret = parts
|
let config = parts
|
||||||
.extensions
|
.extensions
|
||||||
.get::<Arc<Config>>()
|
.get::<Arc<Config>>()
|
||||||
.map(|c| c.jwt_secret.clone())
|
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth config missing"))?;
|
||||||
.or_else(|| std::env::var("JWT_SECRET").ok())
|
|
||||||
.unwrap_or_else(|| "dev-secret-change-in-production".to_string());
|
|
||||||
|
|
||||||
let claims: Claims = verify_token(token, &secret)
|
if config.jwt_secret.trim().is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"JWT secret not configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims: Claims = verify_token(token, &config.jwt_secret)
|
||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
|
||||||
|
|
||||||
Ok(AuthUser {
|
Ok(AuthUser {
|
||||||
|
|
@ -47,3 +65,83 @@ where
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AuthUser;
|
||||||
|
use crate::auth::create_token;
|
||||||
|
use crate::config::Config;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::Request;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn parts_with_auth(
|
||||||
|
auth: Option<&str>,
|
||||||
|
config: Option<Arc<Config>>,
|
||||||
|
) -> axum::http::request::Parts {
|
||||||
|
let mut req = Request::builder().uri("/").body(Body::empty()).unwrap();
|
||||||
|
if let Some(auth) = auth {
|
||||||
|
req.headers_mut()
|
||||||
|
.insert(axum::http::header::AUTHORIZATION, auth.parse().unwrap());
|
||||||
|
}
|
||||||
|
if let Some(config) = config {
|
||||||
|
req.extensions_mut().insert(config);
|
||||||
|
}
|
||||||
|
let (parts, _) = req.into_parts();
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_missing_auth_header() {
|
||||||
|
let config = Arc::new(Config {
|
||||||
|
jwt_secret: "secret".to_string(),
|
||||||
|
..Config::default()
|
||||||
|
});
|
||||||
|
let mut parts = parts_with_auth(None, Some(config));
|
||||||
|
let err = AuthUser::from_request_parts(&mut parts, &())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.0, axum::http::StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn accepts_bearer_case_insensitive_and_whitespace() {
|
||||||
|
let config = Arc::new(Config {
|
||||||
|
jwt_secret: "secret".to_string(),
|
||||||
|
..Config::default()
|
||||||
|
});
|
||||||
|
let token = create_token(Uuid::new_v4(), "alice", &config.jwt_secret).unwrap();
|
||||||
|
let auth = format!(" bEaReR {token} ");
|
||||||
|
let mut parts = parts_with_auth(Some(&auth), Some(config));
|
||||||
|
let user = AuthUser::from_request_parts(&mut parts, &()).await.unwrap();
|
||||||
|
assert_eq!(user.username, "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_non_bearer_scheme() {
|
||||||
|
let config = Arc::new(Config {
|
||||||
|
jwt_secret: "secret".to_string(),
|
||||||
|
..Config::default()
|
||||||
|
});
|
||||||
|
let token = create_token(Uuid::new_v4(), "alice", &config.jwt_secret).unwrap();
|
||||||
|
let auth = format!("Token {token}");
|
||||||
|
let mut parts = parts_with_auth(Some(&auth), Some(config));
|
||||||
|
let err = AuthUser::from_request_parts(&mut parts, &())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.0, axum::http::StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn errors_when_config_missing() {
|
||||||
|
let token = create_token(Uuid::new_v4(), "alice", "secret").unwrap();
|
||||||
|
let auth = format!("Bearer {token}");
|
||||||
|
let mut parts = parts_with_auth(Some(&auth), None);
|
||||||
|
let err = AuthUser::from_request_parts(&mut parts, &())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.0, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ pub struct Config {
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
#[serde(default = "default_server_port")]
|
#[serde(default = "default_server_port")]
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cors_allowed_origins: Option<String>,
|
||||||
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub demo_mode: bool,
|
pub demo_mode: bool,
|
||||||
|
|
@ -74,6 +76,7 @@ impl Default for Config {
|
||||||
database_url: "postgres://likwid:likwid@localhost:5432/likwid".to_string(),
|
database_url: "postgres://likwid:likwid@localhost:5432/likwid".to_string(),
|
||||||
server_host: "127.0.0.1".to_string(),
|
server_host: "127.0.0.1".to_string(),
|
||||||
server_port: 3000,
|
server_port: 3000,
|
||||||
|
cors_allowed_origins: None,
|
||||||
demo_mode: false,
|
demo_mode: false,
|
||||||
jwt_secret: default_jwt_secret(),
|
jwt_secret: default_jwt_secret(),
|
||||||
rate_limit_enabled: default_rate_limit_enabled(),
|
rate_limit_enabled: default_rate_limit_enabled(),
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,101 @@ async fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod security_headers_tests {
|
||||||
|
use super::add_security_headers;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Request, StatusCode};
|
||||||
|
use axum::middleware;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn security_headers_are_added_by_default() {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/ping", get(|| async { "ok" }))
|
||||||
|
.layer(middleware::map_response(add_security_headers));
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/ping")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get("x-content-type-options")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("nosniff")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get("x-frame-options")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("DENY")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get("referrer-policy")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("no-referrer")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get("permissions-policy")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("camera=(), microphone=(), geolocation=()")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get("x-permitted-cross-domain-policies")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("none")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn security_headers_do_not_override_explicit_headers() {
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/ping",
|
||||||
|
get(|| async {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("x-frame-options", "SAMEORIGIN")
|
||||||
|
.header(header::REFERRER_POLICY, "strict-origin")
|
||||||
|
.body(Body::from("ok"))
|
||||||
|
.unwrap()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.layer(middleware::map_response(add_security_headers));
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/ping")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.oneshot(req).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get("x-frame-options")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("SAMEORIGIN")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get(header::REFERRER_POLICY)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("strict-origin")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run() -> Result<(), StartupError> {
|
async fn run() -> Result<(), StartupError> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
|
@ -152,10 +247,7 @@ async fn run() -> Result<(), StartupError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = build_cors_layer(config.as_ref());
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any);
|
|
||||||
|
|
||||||
let plugins = plugins::PluginManager::new(pool.clone())
|
let plugins = plugins::PluginManager::new(pool.clone())
|
||||||
.register_builtin_plugins()
|
.register_builtin_plugins()
|
||||||
|
|
@ -352,5 +444,155 @@ async fn add_security_headers(mut res: Response) -> Response {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !headers.contains_key("permissions-policy") {
|
||||||
|
headers.insert(
|
||||||
|
HeaderName::from_static("permissions-policy"),
|
||||||
|
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !headers.contains_key("x-permitted-cross-domain-policies") {
|
||||||
|
headers.insert(
|
||||||
|
HeaderName::from_static("x-permitted-cross-domain-policies"),
|
||||||
|
HeaderValue::from_static("none"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_cors_layer(config: &Config) -> CorsLayer {
|
||||||
|
let layer = CorsLayer::new().allow_methods(Any).allow_headers(Any);
|
||||||
|
|
||||||
|
let allowed = config
|
||||||
|
.cors_allowed_origins
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty());
|
||||||
|
|
||||||
|
let Some(allowed) = allowed else {
|
||||||
|
return layer.allow_origin(Any);
|
||||||
|
};
|
||||||
|
|
||||||
|
let origins: Vec<HeaderValue> = allowed
|
||||||
|
.split(',')
|
||||||
|
.map(|v| v.trim())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.filter_map(|v| match HeaderValue::from_str(v) {
|
||||||
|
Ok(hv) => Some(hv),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(origin = v, error = %e, "Invalid CORS origin; ignoring");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if origins.is_empty() {
|
||||||
|
return layer.allow_origin(Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.allow_origin(origins)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cors_tests {
|
||||||
|
use super::build_cors_layer;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Request, StatusCode};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cors_default_allows_any_origin() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/ping", get(|| async { "ok" }))
|
||||||
|
.layer(build_cors_layer(&cfg));
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/ping")
|
||||||
|
.header(header::ORIGIN, "https://example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let res = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("*")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cors_allowlist_only_allows_listed_origins() {
|
||||||
|
let cfg = Config {
|
||||||
|
cors_allowed_origins: Some("https://a.example, https://b.example".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/ping", get(|| async { "ok" }))
|
||||||
|
.layer(build_cors_layer(&cfg));
|
||||||
|
|
||||||
|
let allowed_req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/ping")
|
||||||
|
.header(header::ORIGIN, "https://a.example")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let allowed_res = app.clone().oneshot(allowed_req).await.unwrap();
|
||||||
|
assert_eq!(allowed_res.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
allowed_res
|
||||||
|
.headers()
|
||||||
|
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("https://a.example")
|
||||||
|
);
|
||||||
|
|
||||||
|
let denied_req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/ping")
|
||||||
|
.header(header::ORIGIN, "https://c.example")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let denied_res = app.clone().oneshot(denied_req).await.unwrap();
|
||||||
|
assert_eq!(denied_res.status(), StatusCode::OK);
|
||||||
|
assert!(denied_res
|
||||||
|
.headers()
|
||||||
|
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cors_preflight_responds_for_allowed_origin() {
|
||||||
|
let cfg = Config {
|
||||||
|
cors_allowed_origins: Some("https://a.example".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/ping", get(|| async { "ok" }))
|
||||||
|
.layer(build_cors_layer(&cfg));
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("OPTIONS")
|
||||||
|
.uri("/api/ping")
|
||||||
|
.header(header::ORIGIN, "https://a.example")
|
||||||
|
.header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res = app.oneshot(req).await.unwrap();
|
||||||
|
assert!(res.status().is_success());
|
||||||
|
assert_eq!(
|
||||||
|
res.headers()
|
||||||
|
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("https://a.example")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,15 @@ fn parse_ip_from_connect_info<B>(request: &Request<B>) -> Option<IpAddr> {
|
||||||
|
|
||||||
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
|
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
|
||||||
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||||
let token = auth.strip_prefix("Bearer ")?;
|
let mut pieces = auth.split_whitespace();
|
||||||
|
let scheme = pieces.next()?;
|
||||||
|
let token = pieces.next()?;
|
||||||
|
if pieces.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !scheme.eq_ignore_ascii_case("bearer") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
verify_token(token, secret).ok().map(|c| c.sub)
|
verify_token(token, secret).ok().map(|c| c.sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,12 +250,13 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn middleware_bypasses_health() {
|
async fn middleware_bypasses_health() {
|
||||||
let mut cfg = Config::default();
|
let cfg = Arc::new(Config {
|
||||||
cfg.rate_limit_enabled = true;
|
rate_limit_enabled: true,
|
||||||
cfg.rate_limit_ip_rpm = 1;
|
rate_limit_ip_rpm: 1,
|
||||||
cfg.rate_limit_user_rpm = 0;
|
rate_limit_user_rpm: 0,
|
||||||
cfg.rate_limit_auth_rpm = 0;
|
rate_limit_auth_rpm: 0,
|
||||||
let cfg = Arc::new(cfg);
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
|
|
@ -270,12 +279,13 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn middleware_enforces_ip_rate_limit() {
|
async fn middleware_enforces_ip_rate_limit() {
|
||||||
let mut cfg = Config::default();
|
let cfg = Arc::new(Config {
|
||||||
cfg.rate_limit_enabled = true;
|
rate_limit_enabled: true,
|
||||||
cfg.rate_limit_ip_rpm = 2;
|
rate_limit_ip_rpm: 2,
|
||||||
cfg.rate_limit_user_rpm = 0;
|
rate_limit_user_rpm: 0,
|
||||||
cfg.rate_limit_auth_rpm = 0;
|
rate_limit_auth_rpm: 0,
|
||||||
let cfg = Arc::new(cfg);
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/ping", get(|| async { "ok" }))
|
.route("/api/ping", get(|| async { "ok" }))
|
||||||
|
|
@ -308,12 +318,13 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn middleware_enforces_auth_rate_limit_on_login() {
|
async fn middleware_enforces_auth_rate_limit_on_login() {
|
||||||
let mut cfg = Config::default();
|
let cfg = Arc::new(Config {
|
||||||
cfg.rate_limit_enabled = true;
|
rate_limit_enabled: true,
|
||||||
cfg.rate_limit_ip_rpm = 0;
|
rate_limit_ip_rpm: 0,
|
||||||
cfg.rate_limit_user_rpm = 0;
|
rate_limit_user_rpm: 0,
|
||||||
cfg.rate_limit_auth_rpm = 1;
|
rate_limit_auth_rpm: 1,
|
||||||
let cfg = Arc::new(cfg);
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/auth/login", get(|| async { "ok" }))
|
.route("/api/auth/login", get(|| async { "ok" }))
|
||||||
|
|
@ -344,13 +355,14 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn middleware_enforces_user_rate_limit_when_token_is_valid() {
|
async fn middleware_enforces_user_rate_limit_when_token_is_valid() {
|
||||||
let mut cfg = Config::default();
|
let cfg = Arc::new(Config {
|
||||||
cfg.rate_limit_enabled = true;
|
rate_limit_enabled: true,
|
||||||
cfg.rate_limit_ip_rpm = 0;
|
rate_limit_ip_rpm: 0,
|
||||||
cfg.rate_limit_user_rpm = 1;
|
rate_limit_user_rpm: 1,
|
||||||
cfg.rate_limit_auth_rpm = 0;
|
rate_limit_auth_rpm: 0,
|
||||||
cfg.jwt_secret = "testsecret".to_string();
|
jwt_secret: "testsecret".to_string(),
|
||||||
let cfg = Arc::new(cfg);
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let token = create_token(user_id, "u", &cfg.jwt_secret).unwrap();
|
let token = create_token(user_id, "u", &cfg.jwt_secret).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Likwid is configured through environment variables and database settings.
|
||||||
| `SERVER_HOST` | No | `127.0.0.1` | Bind address |
|
| `SERVER_HOST` | No | `127.0.0.1` | Bind address |
|
||||||
| `SERVER_PORT` | No | `3000` | HTTP port |
|
| `SERVER_PORT` | No | `3000` | HTTP port |
|
||||||
| `DEMO_MODE` | No | `false` | Enable demo features |
|
| `DEMO_MODE` | No | `false` | Enable demo features |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
|
||||||
| `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting |
|
| `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting |
|
||||||
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
||||||
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
||||||
|
|
@ -118,12 +119,18 @@ Disabling:
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
By default, CORS allows all origins in development. For production:
|
By default, CORS allows any origin (development-friendly). In production, set `CORS_ALLOWED_ORIGINS` to a comma-separated allowlist.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CORS_ALLOWED_ORIGINS=https://likwid.example.org
|
CORS_ALLOWED_ORIGINS=https://likwid.example.org
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Multiple origins:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
|
||||||
|
```
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
### Log Levels
|
### Log Levels
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ cp compose/.env.production.example compose/.env.production
|
||||||
```
|
```
|
||||||
|
|
||||||
Required settings:
|
Required settings:
|
||||||
|
|
||||||
- `POSTGRES_PASSWORD` - Strong database password
|
- `POSTGRES_PASSWORD` - Strong database password
|
||||||
- `JWT_SECRET` - Random 64+ character string
|
- `JWT_SECRET` - Random 64+ character string
|
||||||
|
|
||||||
|
|
@ -38,8 +39,8 @@ podman-compose --env-file .env.production -f production.yml up -d
|
||||||
|
|
||||||
### 4. Access
|
### 4. Access
|
||||||
|
|
||||||
- Frontend: http://localhost:4321
|
- Frontend: <http://localhost:4321>
|
||||||
- Backend API: http://localhost:3000
|
- Backend API: <http://localhost:3000>
|
||||||
|
|
||||||
## Manual Installation
|
## Manual Installation
|
||||||
|
|
||||||
|
|
@ -76,19 +77,21 @@ node ./dist/server/entry.mjs
|
||||||
|
|
||||||
## Configuration Files
|
## Configuration Files
|
||||||
|
|
||||||
| File | Purpose |
|
|File|Purpose|
|
||||||
|------|---------|
|
|---|---|
|
||||||
| `compose/production.yml` | Production container deployment |
|
|`compose/production.yml`|Production container deployment|
|
||||||
| `compose/demo.yml` | Demo instance deployment |
|
|`compose/demo.yml`|Demo instance deployment|
|
||||||
| `compose/.env.production.example` | Environment template |
|
|`compose/.env.production.example`|Environment template|
|
||||||
| `backend/.env` | Backend configuration |
|
|`backend/.env`|Backend configuration|
|
||||||
|
|
||||||
## Reverse Proxy
|
## Reverse Proxy
|
||||||
|
|
||||||
For production, use a reverse proxy (nginx, Caddy) with:
|
For production, use a reverse proxy (nginx, Caddy) with:
|
||||||
|
|
||||||
- HTTPS termination
|
- HTTPS termination
|
||||||
- WebSocket support (for real-time features)
|
- WebSocket support (for real-time features)
|
||||||
- Proper headers
|
- Proper headers
|
||||||
|
- HSTS (set on the reverse proxy)
|
||||||
|
|
||||||
Example nginx config:
|
Example nginx config:
|
||||||
|
|
||||||
|
|
@ -107,12 +110,16 @@ server {
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -5,36 +5,64 @@ Securing your Likwid instance.
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
### JWT Tokens
|
### JWT Tokens
|
||||||
|
|
||||||
- Use a strong, random `JWT_SECRET` (64+ characters)
|
- Use a strong, random `JWT_SECRET` (64+ characters)
|
||||||
- Tokens expire after 24 hours by default
|
- Tokens expire after 24 hours by default
|
||||||
- Refresh tokens are not stored server-side
|
- Refresh tokens are not stored server-side
|
||||||
|
|
||||||
### Password Policy
|
### Password Policy
|
||||||
|
|
||||||
- Minimum 8 characters (configurable)
|
- Minimum 8 characters (configurable)
|
||||||
- Bcrypt hashing with cost factor 12
|
- Bcrypt hashing with cost factor 12
|
||||||
- No password in logs or error messages
|
- No password in logs or error messages
|
||||||
|
|
||||||
### Two-Factor Authentication
|
### Two-Factor Authentication
|
||||||
|
|
||||||
Enable 2FA support for users:
|
Enable 2FA support for users:
|
||||||
|
|
||||||
- TOTP (Time-based One-Time Password)
|
- TOTP (Time-based One-Time Password)
|
||||||
- Backup codes for recovery
|
- Backup codes for recovery
|
||||||
|
|
||||||
## Network Security
|
## Network Security
|
||||||
|
|
||||||
### HTTPS
|
### HTTPS
|
||||||
|
|
||||||
Always use HTTPS in production:
|
Always use HTTPS in production:
|
||||||
|
|
||||||
- Obtain certificates (Let's Encrypt recommended)
|
- Obtain certificates (Let's Encrypt recommended)
|
||||||
- Configure reverse proxy for TLS termination
|
- Configure reverse proxy for TLS termination
|
||||||
- Enable HSTS headers
|
- Enable HSTS headers
|
||||||
|
|
||||||
|
### Security Headers
|
||||||
|
|
||||||
|
The backend sets a small set of security headers on responses by default:
|
||||||
|
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options: DENY`
|
||||||
|
- `Referrer-Policy: no-referrer`
|
||||||
|
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||||
|
- `X-Permitted-Cross-Domain-Policies: none`
|
||||||
|
|
||||||
|
Set `Strict-Transport-Security` (HSTS) on your reverse proxy, because the backend does not know whether requests arrived via HTTPS.
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
Restrict CORS in production:
|
Restrict CORS in production:
|
||||||
```
|
|
||||||
|
```bash
|
||||||
CORS_ALLOWED_ORIGINS=https://likwid.example.org
|
CORS_ALLOWED_ORIGINS=https://likwid.example.org
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For multiple origins, use a comma-separated list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
|
||||||
|
```
|
||||||
|
|
||||||
### Rate Limiting
|
### Rate Limiting
|
||||||
|
|
||||||
Protect against abuse:
|
Protect against abuse:
|
||||||
|
|
||||||
- 300 requests/minute per IP (default)
|
- 300 requests/minute per IP (default)
|
||||||
- 1200 requests/minute per authenticated user
|
- 1200 requests/minute per authenticated user
|
||||||
- 30 requests/minute per IP for auth endpoints
|
- 30 requests/minute per IP for auth endpoints
|
||||||
|
|
@ -42,11 +70,13 @@ Protect against abuse:
|
||||||
## Database Security
|
## Database Security
|
||||||
|
|
||||||
### Connection
|
### Connection
|
||||||
|
|
||||||
- Use SSL for database connections
|
- Use SSL for database connections
|
||||||
- Dedicated database user with minimal privileges
|
- Dedicated database user with minimal privileges
|
||||||
- Strong, unique password
|
- Strong, unique password
|
||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
|
|
||||||
- Regular automated backups
|
- Regular automated backups
|
||||||
- Encrypted backup storage
|
- Encrypted backup storage
|
||||||
- Test restore procedures
|
- Test restore procedures
|
||||||
|
|
@ -54,16 +84,20 @@ Protect against abuse:
|
||||||
## API Security
|
## API Security
|
||||||
|
|
||||||
### Input Validation
|
### Input Validation
|
||||||
|
|
||||||
All inputs are validated:
|
All inputs are validated:
|
||||||
|
|
||||||
- Type checking
|
- Type checking
|
||||||
- Length limits
|
- Length limits
|
||||||
- Sanitization
|
- Sanitization
|
||||||
|
|
||||||
### SQL Injection
|
### SQL Injection
|
||||||
|
|
||||||
- Parameterized queries only (SQLx)
|
- Parameterized queries only (SQLx)
|
||||||
- No raw SQL string concatenation
|
- No raw SQL string concatenation
|
||||||
|
|
||||||
### XSS Prevention
|
### XSS Prevention
|
||||||
|
|
||||||
- HTML escaping in templates
|
- HTML escaping in templates
|
||||||
- Content Security Policy headers
|
- Content Security Policy headers
|
||||||
- No inline scripts in production
|
- No inline scripts in production
|
||||||
|
|
@ -71,6 +105,7 @@ All inputs are validated:
|
||||||
## Moderation Audit Trail
|
## Moderation Audit Trail
|
||||||
|
|
||||||
All moderation actions are logged:
|
All moderation actions are logged:
|
||||||
|
|
||||||
- Who performed the action
|
- Who performed the action
|
||||||
- What action was taken
|
- What action was taken
|
||||||
- Why (reason required)
|
- Why (reason required)
|
||||||
|
|
@ -81,6 +116,7 @@ Logs are immutable and tamper-evident.
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Keep Likwid updated:
|
Keep Likwid updated:
|
||||||
|
|
||||||
- Watch the repository for security announcements
|
- Watch the repository for security announcements
|
||||||
- Apply patches promptly
|
- Apply patches promptly
|
||||||
- Test updates in staging first
|
- Test updates in staging first
|
||||||
|
|
@ -88,6 +124,7 @@ Keep Likwid updated:
|
||||||
## Incident Response
|
## Incident Response
|
||||||
|
|
||||||
If you discover a security issue:
|
If you discover a security issue:
|
||||||
|
|
||||||
1. Document the incident
|
1. Document the incident
|
||||||
2. Assess impact
|
2. Assess impact
|
||||||
3. Contain the breach
|
3. Contain the breach
|
||||||
|
|
@ -96,6 +133,6 @@ If you discover a security issue:
|
||||||
|
|
||||||
## Reporting Vulnerabilities
|
## Reporting Vulnerabilities
|
||||||
|
|
||||||
Report security issues to: security@likwid.org
|
Report security issues to: [security@likwid.org](mailto:security@likwid.org)
|
||||||
|
|
||||||
We follow responsible disclosure practices.
|
We follow responsible disclosure practices.
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,15 @@
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.theme-select {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-select:hover {
|
.theme-select:hover {
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#main-content {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0.75rem;
|
top: 0.75rem;
|
||||||
|
|
@ -138,6 +142,18 @@
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-primary-muted);
|
background: var(--color-primary-muted);
|
||||||
}
|
}
|
||||||
|
|
@ -159,11 +175,11 @@
|
||||||
color: var(--color-primary-hover);
|
color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(a, button, input, textarea, select):focus {
|
:where(a, button, input, textarea, select, summary):focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(a, button, input, textarea, select):focus-visible {
|
:where(a, button, input, textarea, select, summary):focus-visible {
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--focus-ring);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
@ -1016,4 +1032,20 @@
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.ui-btn {
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-lg {
|
||||||
|
padding: 0.8rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-xl {
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,12 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
|
||||||
navToggle.setAttribute('aria-expanded', 'true');
|
navToggle.setAttribute('aria-expanded', 'true');
|
||||||
if (window.matchMedia('(max-width: 640px)').matches) {
|
if (window.matchMedia('(max-width: 640px)').matches) {
|
||||||
document.body.classList.add('is-scroll-locked');
|
document.body.classList.add('is-scroll-locked');
|
||||||
|
const firstFocusable = nav.querySelector(
|
||||||
|
'#nav-menu a, #nav-menu button, #nav-menu input, #nav-menu select, #nav-menu textarea, #nav-menu summary'
|
||||||
|
);
|
||||||
|
if (firstFocusable instanceof HTMLElement) {
|
||||||
|
firstFocusable.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +142,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
|
||||||
nav.classList.remove('is-open');
|
nav.classList.remove('is-open');
|
||||||
navToggle.setAttribute('aria-expanded', 'false');
|
navToggle.setAttribute('aria-expanded', 'false');
|
||||||
document.body.classList.remove('is-scroll-locked');
|
document.body.classList.remove('is-scroll-locked');
|
||||||
|
navToggle.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nav && navToggle) {
|
if (nav && navToggle) {
|
||||||
|
|
@ -187,8 +194,9 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
|
||||||
const userData = JSON.parse(user);
|
const userData = JSON.parse(user);
|
||||||
navAuth.innerHTML = `
|
navAuth.innerHTML = `
|
||||||
<a href="/dashboard">Dashboard</a>
|
<a href="/dashboard">Dashboard</a>
|
||||||
<a href="/notifications" class="nav-notifications" id="nav-notifications">
|
<a href="/notifications" class="nav-notifications" id="nav-notifications" aria-label="Notifications">
|
||||||
<span class="notif-icon">🔔</span>
|
<span class="notif-icon">🔔</span>
|
||||||
|
<span class="sr-only">Notifications</span>
|
||||||
<span class="notif-badge" id="notif-badge" style="display:none;">0</span>
|
<span class="notif-badge" id="notif-badge" style="display:none;">0</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,12 @@ const defaultTheme = DEFAULT_THEME;
|
||||||
toggle.setAttribute('aria-expanded', 'true');
|
toggle.setAttribute('aria-expanded', 'true');
|
||||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||||
document.body.classList.add('is-scroll-locked');
|
document.body.classList.add('is-scroll-locked');
|
||||||
|
const firstFocusable = nav.querySelector(
|
||||||
|
'#public-nav-menu a, #public-nav-menu button, #public-nav-menu input, #public-nav-menu select, #public-nav-menu textarea, #public-nav-menu summary'
|
||||||
|
);
|
||||||
|
if (firstFocusable instanceof HTMLElement) {
|
||||||
|
firstFocusable.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +179,7 @@ const defaultTheme = DEFAULT_THEME;
|
||||||
nav.classList.remove('is-open');
|
nav.classList.remove('is-open');
|
||||||
toggle.setAttribute('aria-expanded', 'false');
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
document.body.classList.remove('is-scroll-locked');
|
document.body.classList.remove('is-scroll-locked');
|
||||||
|
toggle.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveNav() {
|
function setActiveNav() {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
var allCommunities = [];
|
var allCommunities = [];
|
||||||
var currentQuery = '';
|
var currentQuery = '';
|
||||||
var currentSort = 'name_asc';
|
var currentSort = 'name_asc';
|
||||||
|
var token = localStorage.getItem('token');
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || '').replace(/[&<>\"']/g, function(ch) {
|
return String(value || '').replace(/[&<>\"']/g, function(ch) {
|
||||||
|
|
@ -122,8 +123,18 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
var container = document.getElementById('communities-list');
|
var container = document.getElementById('communities-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
var label = isFiltered ? 'No communities match your search.' : 'No communities found.';
|
var isLoggedIn = !!token;
|
||||||
var hint = isFiltered ? 'Try a different keyword or clear the search.' : 'Please try again later.';
|
var label;
|
||||||
|
var hint;
|
||||||
|
if (isFiltered) {
|
||||||
|
label = 'No communities match your search.';
|
||||||
|
hint = 'Try a different keyword or clear the search.';
|
||||||
|
} else {
|
||||||
|
label = isLoggedIn ? 'No communities yet.' : 'No communities found.';
|
||||||
|
hint = isLoggedIn
|
||||||
|
? 'Create the first community to start organizing proposals.'
|
||||||
|
: 'Sign in to create a community, or refresh to try again.';
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div class="state-card ui-card">' +
|
'<div class="state-card ui-card">' +
|
||||||
|
|
@ -131,6 +142,8 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
'<p class="hint">' + hint + '</p>' +
|
'<p class="hint">' + hint + '</p>' +
|
||||||
'<div class="state-actions">' +
|
'<div class="state-actions">' +
|
||||||
(isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-community-search">Reset search</button>' : '') +
|
(isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-community-search">Reset search</button>' : '') +
|
||||||
|
(!isFiltered && isLoggedIn ? '<a class="ui-btn ui-btn-primary" href="/communities/new">Create Community</a>' : '') +
|
||||||
|
(!isFiltered && !isLoggedIn ? '<a class="ui-btn ui-btn-secondary" href="/login">Sign In</a>' : '') +
|
||||||
'<a class="ui-btn ui-btn-primary" href="/communities">Refresh</a>' +
|
'<a class="ui-btn ui-btn-primary" href="/communities">Refresh</a>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
@ -265,7 +278,6 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show create button if logged in
|
// Show create button if logged in
|
||||||
var token = localStorage.getItem('token');
|
|
||||||
var createBtn = document.getElementById('create-btn');
|
var createBtn = document.getElementById('create-btn');
|
||||||
if (token && createBtn) {
|
if (token && createBtn) {
|
||||||
createBtn.style.display = 'block';
|
createBtn.style.display = 'block';
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ const { slug } = Astro.params;
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="proposals-list" class="list">
|
<div id="proposals-list" class="list" aria-live="polite">
|
||||||
<div class="list-skeleton">
|
<div class="list-skeleton">
|
||||||
<div class="skeleton-card"></div>
|
<div class="skeleton-card"></div>
|
||||||
<div class="skeleton-card"></div>
|
<div class="skeleton-card"></div>
|
||||||
|
|
@ -154,7 +154,18 @@ const { slug } = Astro.params;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (proposals.length === 0) {
|
if (proposals.length === 0) {
|
||||||
container.innerHTML = '<div class="ui-card"><p class="empty">No proposals found.</p></div>';
|
const token = localStorage.getItem('token');
|
||||||
|
const isLoggedIn = !!token;
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="state-card ui-card">
|
||||||
|
<p class="empty">No proposals yet.</p>
|
||||||
|
<p class="hint">Create the first proposal for this community to start collecting feedback and votes.</p>
|
||||||
|
<div class="state-actions">
|
||||||
|
${isLoggedIn ? `<a class="ui-btn ui-btn-primary" href="/communities/${encodeURIComponent(String(slug))}/proposals/new">+ New Proposal</a>` : '<a class="ui-btn ui-btn-secondary" href="/login">Sign In</a>'}
|
||||||
|
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug))}">Back to community</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,7 @@ const nextQuery = nextParam ? `&next=${encodeURIComponent(nextParam)}` : '';
|
||||||
.card-stats {
|
.card-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
|
@ -708,6 +709,14 @@ const nextQuery = nextParam ? `&next=${encodeURIComponent(nextParam)}` : '';
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.journey-step {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.step-number {
|
.step-number {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|
|
||||||
|
|
@ -100,13 +100,18 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
const container = document.getElementById('proposals-list');
|
const container = document.getElementById('proposals-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
const label = isFiltered ? 'No proposals match your filters.' : 'No proposals yet.';
|
||||||
|
const hint = isFiltered
|
||||||
|
? 'Try adjusting search, status, or sort.'
|
||||||
|
: 'To create one, open a community and click New Proposal.';
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="state-card ui-card">
|
<div class="state-card ui-card">
|
||||||
<p class="empty">${isFiltered ? 'No proposals match your filters.' : 'No proposals found.'}</p>
|
<p class="empty">${label}</p>
|
||||||
<p class="hint">Try adjusting search, status, or sort.</p>
|
<p class="hint">${hint}</p>
|
||||||
<div class="state-actions">
|
<div class="state-actions">
|
||||||
<button type="button" class="ui-btn ui-btn-secondary" id="reset-filters">Reset filters</button>
|
${isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-filters">Reset filters</button>' : ''}
|
||||||
<a class="ui-btn ui-btn-primary" href="/communities">Go to communities</a>
|
<a class="ui-btn ui-btn-primary" href="/communities">Browse communities</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,8 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
--ui-form-group-mb: 1.25rem;
|
--ui-form-group-mb: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,6 +191,17 @@ import { API_BASE as apiBase } from '../lib/api';
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-page {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.settings-form {
|
.settings-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue