Initialize ChuBlog backend with SSH Git publishing
This commit is contained in:
commit
a631d75cee
31 files changed
+4850
No files matched your search
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "chublog-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
argon2 = "0.5"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chusql-driver = { path = "../../crates/chusql-driver" }
|
||||
fs2 = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
pulldown-cmark = "0.13"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -0,0 +1,657 @@
|
||||
use crate::{ApiError, ApiResult, App, bad, internal, origin, ssh};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
path::Path as FilePath,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
Reader,
|
||||
Publisher,
|
||||
Admin,
|
||||
}
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Grant {
|
||||
pub enabled: bool,
|
||||
pub role: Role,
|
||||
pub prefixes: Vec<String>,
|
||||
pub ssh_key: Option<String>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Credential {
|
||||
hash: String,
|
||||
}
|
||||
struct Session {
|
||||
username: String,
|
||||
csrf: String,
|
||||
expires: Instant,
|
||||
}
|
||||
pub struct Accounts {
|
||||
credentials: BTreeMap<String, Credential>,
|
||||
pub grants: ssh::Config,
|
||||
sessions: BTreeMap<String, Session>,
|
||||
attempts: VecDeque<Instant>,
|
||||
changes: VecDeque<Instant>,
|
||||
}
|
||||
|
||||
pub async fn atomic(path: &FilePath, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.mode(mode)
|
||||
.open(&tmp)
|
||||
.await?;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await?;
|
||||
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode)).await?;
|
||||
tokio::fs::rename(&tmp, path).await?;
|
||||
let directory = std::fs::File::open(path.parent().context("文件缺少父目录")?)?;
|
||||
directory.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn username(value: &str) -> Result<()> {
|
||||
ensure!(
|
||||
(3..=32).contains(&value.len())
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& value.starts_with(|c: char| c.is_ascii_lowercase()),
|
||||
"用户名必须为 3–32 位小写字母、数字或短横线,以字母开头"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn password(value: &str) -> Result<()> {
|
||||
ensure!(
|
||||
(12..=1024).contains(&value.len()),
|
||||
"口令必须为 12–1024 字节"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub async fn hash_password(value: String) -> Result<String> {
|
||||
password(&value)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
Argon2::default()
|
||||
.hash_password(value.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
})
|
||||
.await?
|
||||
}
|
||||
pub async fn load(
|
||||
data: &FilePath,
|
||||
config: &FilePath,
|
||||
repository: std::path::PathBuf,
|
||||
) -> Result<Accounts> {
|
||||
let credentials: BTreeMap<String, Credential> =
|
||||
match tokio::fs::read(data.join("users.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let grants = match tokio::fs::read(config).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => ssh::Config {
|
||||
repository,
|
||||
publishers: BTreeMap::new(),
|
||||
},
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut accounts = Accounts {
|
||||
credentials,
|
||||
grants,
|
||||
sessions: BTreeMap::new(),
|
||||
attempts: VecDeque::new(),
|
||||
changes: VecDeque::new(),
|
||||
};
|
||||
if accounts.credentials.is_empty() {
|
||||
let name = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".into());
|
||||
username(&name)?;
|
||||
let hash =
|
||||
std::env::var("ADMIN_PASSWORD_HASH").context("首次启动必须设置 ADMIN_PASSWORD_HASH")?;
|
||||
let parsed = PasswordHash::new(&hash).map_err(|_| anyhow::anyhow!("管理员哈希格式无效"))?;
|
||||
ensure!(
|
||||
parsed.algorithm.as_str() == "argon2id",
|
||||
"管理员哈希必须为 Argon2id"
|
||||
);
|
||||
accounts
|
||||
.credentials
|
||||
.insert(name.clone(), Credential { hash });
|
||||
accounts.grants.publishers.insert(
|
||||
name,
|
||||
Grant {
|
||||
enabled: true,
|
||||
role: Role::Admin,
|
||||
prefixes: vec![],
|
||||
ssh_key: None,
|
||||
},
|
||||
);
|
||||
atomic(config, &serde_json::to_vec_pretty(&accounts.grants)?, 0o644).await?;
|
||||
atomic(
|
||||
&data.join("users.json"),
|
||||
&serde_json::to_vec(&accounts.credentials)?,
|
||||
0o600,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ensure!(
|
||||
accounts
|
||||
.grants
|
||||
.publishers
|
||||
.iter()
|
||||
.any(|(name, g)| g.role == Role::Admin
|
||||
&& g.enabled
|
||||
&& accounts.credentials.contains_key(name)),
|
||||
"至少需要一个启用的管理员"
|
||||
);
|
||||
Ok(accounts)
|
||||
}
|
||||
fn rate(accounts: &mut Accounts) -> ApiResult<()> {
|
||||
let now = Instant::now();
|
||||
while accounts
|
||||
.attempts
|
||||
.front()
|
||||
.is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60))
|
||||
{
|
||||
accounts.attempts.pop_front();
|
||||
}
|
||||
// One global bucket also bounds attacks distributed across IPs and account names.
|
||||
if accounts.attempts.len() >= 10 {
|
||||
return Err(ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"认证请求过于频繁,请稍后再试".into(),
|
||||
));
|
||||
}
|
||||
accounts.attempts.push_back(now);
|
||||
Ok(())
|
||||
}
|
||||
fn token() -> String {
|
||||
let mut bytes = [0; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
pub async fn authorize(
|
||||
app: &App,
|
||||
headers: &HeaderMap,
|
||||
change: bool,
|
||||
admin: bool,
|
||||
) -> ApiResult<String> {
|
||||
let cookie = headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.split(';')
|
||||
.filter_map(|p| p.trim().split_once('='))
|
||||
.find(|(name, _)| *name == "chublog_session")
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or("");
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
let session = accounts
|
||||
.sessions
|
||||
.get(cookie)
|
||||
.filter(|s| s.expires > Instant::now())
|
||||
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "请先登录".into()))?;
|
||||
let grant = accounts
|
||||
.grants
|
||||
.publishers
|
||||
.get(&session.username)
|
||||
.filter(|g| g.enabled)
|
||||
.ok_or_else(|| ApiError(StatusCode::FORBIDDEN, "用户已停用".into()))?;
|
||||
if admin && grant.role != Role::Admin {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "需要管理员权限".into()));
|
||||
}
|
||||
if change {
|
||||
origin(app, headers)?;
|
||||
if headers.get("x-csrf-token").and_then(|v| v.to_str().ok()) != Some(session.csrf.as_str())
|
||||
{
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "CSRF 校验失败".into()));
|
||||
}
|
||||
}
|
||||
let username = session.username.clone();
|
||||
if change {
|
||||
let now = Instant::now();
|
||||
while accounts
|
||||
.changes
|
||||
.front()
|
||||
.is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60))
|
||||
{
|
||||
accounts.changes.pop_front();
|
||||
}
|
||||
if accounts.changes.len() >= 60 {
|
||||
return Err(ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"修改请求过于频繁".into(),
|
||||
));
|
||||
}
|
||||
accounts.changes.push_back(now);
|
||||
}
|
||||
Ok(username)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Login {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
pub async fn register(
|
||||
State(app): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<Login>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
origin(&app, &headers)?;
|
||||
if !app.registration {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "注册已关闭".into()));
|
||||
}
|
||||
username(&input.username).map_err(bad)?;
|
||||
password(&input.password).map_err(bad)?;
|
||||
{
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
rate(&mut accounts)?;
|
||||
if accounts.credentials.len() >= 1000 {
|
||||
return Err(bad("用户配额已满"));
|
||||
}
|
||||
}
|
||||
let _permit = app
|
||||
.hash_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?;
|
||||
let hash = hash_password(input.password).await.map_err(internal)?;
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
if accounts.credentials.contains_key(&input.username) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "用户名不可用".into()));
|
||||
}
|
||||
if accounts.credentials.len() >= 1000 {
|
||||
return Err(bad("用户配额已满"));
|
||||
}
|
||||
let mut grants = accounts.grants.clone();
|
||||
grants.publishers.insert(
|
||||
input.username.clone(),
|
||||
Grant {
|
||||
enabled: true,
|
||||
role: Role::Reader,
|
||||
prefixes: vec![],
|
||||
ssh_key: None,
|
||||
},
|
||||
);
|
||||
atomic(
|
||||
&app.ssh_config,
|
||||
&serde_json::to_vec_pretty(&grants).map_err(internal)?,
|
||||
0o644,
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
accounts.grants = grants;
|
||||
accounts
|
||||
.credentials
|
||||
.insert(input.username.clone(), Credential { hash });
|
||||
if let Err(error) = atomic(
|
||||
&app.data.join("users.json"),
|
||||
&serde_json::to_vec(&accounts.credentials).map_err(internal)?,
|
||||
0o600,
|
||||
)
|
||||
.await
|
||||
{
|
||||
accounts.credentials.remove(&input.username);
|
||||
return Err(internal(error));
|
||||
}
|
||||
tracing::info!(action = "register", user = %input.username);
|
||||
Ok(Json(json!({"username": input.username, "role": "reader"})))
|
||||
}
|
||||
pub async fn login(
|
||||
State(app): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<Login>,
|
||||
) -> ApiResult<Response> {
|
||||
origin(&app, &headers)?;
|
||||
username(&input.username).map_err(bad)?;
|
||||
password(&input.password).map_err(bad)?;
|
||||
let hash = {
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
rate(&mut accounts)?;
|
||||
accounts
|
||||
.credentials
|
||||
.get(&input.username)
|
||||
.or_else(|| accounts.credentials.values().next())
|
||||
.map(|c| c.hash.clone())
|
||||
.ok_or_else(|| internal("无管理员"))?
|
||||
};
|
||||
let _permit = app
|
||||
.hash_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?;
|
||||
let verified = tokio::task::spawn_blocking(move || {
|
||||
PasswordHash::new(&hash).ok().is_some_and(|h| {
|
||||
Argon2::default()
|
||||
.verify_password(input.password.as_bytes(), &h)
|
||||
.is_ok()
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
if !verified
|
||||
|| !accounts.credentials.contains_key(&input.username)
|
||||
|| !accounts
|
||||
.grants
|
||||
.publishers
|
||||
.get(&input.username)
|
||||
.is_some_and(|g| g.enabled)
|
||||
{
|
||||
tracing::warn!(action = "login_failed", user = %input.username);
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "登录失败".into()));
|
||||
}
|
||||
let now = Instant::now();
|
||||
accounts
|
||||
.sessions
|
||||
.retain(|_, s| s.expires > now && s.username != input.username);
|
||||
if accounts.sessions.len() >= 1000 {
|
||||
return Err(bad("会话配额已满"));
|
||||
}
|
||||
let id = token();
|
||||
let csrf = token();
|
||||
accounts.sessions.insert(
|
||||
id.clone(),
|
||||
Session {
|
||||
username: input.username.clone(),
|
||||
csrf: csrf.clone(),
|
||||
expires: now + Duration::from_secs(3600),
|
||||
},
|
||||
);
|
||||
let cookie = format!(
|
||||
"chublog_session={id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=3600{}",
|
||||
if app.secure { "; Secure" } else { "" }
|
||||
);
|
||||
let mut response = Json(json!({"csrf": csrf, "username": input.username, "role": accounts.grants.publishers[&input.username].role})).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(internal)?,
|
||||
);
|
||||
tracing::info!(action = "login", user = %input.username);
|
||||
Ok(response)
|
||||
}
|
||||
pub async fn me(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Json<Value>> {
|
||||
let name = authorize(&app, &headers, false, false).await?;
|
||||
let accounts = app.accounts.lock().await;
|
||||
let csrf = accounts
|
||||
.sessions
|
||||
.values()
|
||||
.find(|s| s.username == name)
|
||||
.map(|s| &s.csrf);
|
||||
Ok(Json(
|
||||
json!({"username": name, "permissions": accounts.grants.publishers[&name], "csrf": csrf}),
|
||||
))
|
||||
}
|
||||
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Response> {
|
||||
let name = authorize(&app, &headers, true, false).await?;
|
||||
app.accounts
|
||||
.lock()
|
||||
.await
|
||||
.sessions
|
||||
.retain(|_, s| s.username != name);
|
||||
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&format!(
|
||||
"chublog_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{}",
|
||||
if app.secure { "; Secure" } else { "" }
|
||||
))
|
||||
.map_err(internal)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
pub fn public_key(value: &str) -> Result<String> {
|
||||
ensure!(
|
||||
value.len() < 512 && !value.contains(['\n', '\r']),
|
||||
"公钥格式无效"
|
||||
);
|
||||
let parts: Vec<_> = value.split_whitespace().collect();
|
||||
ensure!(
|
||||
parts.len() >= 2 && parts[0] == "ssh-ed25519",
|
||||
"仅支持 Ed25519 公钥,禁止 authorized_keys 选项"
|
||||
);
|
||||
let bytes = STANDARD.decode(parts[1])?;
|
||||
ensure!(
|
||||
bytes.len() == 51
|
||||
&& bytes[..4] == [0, 0, 0, 11]
|
||||
&& &bytes[4..15] == b"ssh-ed25519"
|
||||
&& bytes[15..19] == [0, 0, 0, 32],
|
||||
"Ed25519 公钥内容无效"
|
||||
);
|
||||
Ok(format!("ssh-ed25519 {}", STANDARD.encode(bytes)))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyInput {
|
||||
public_key: String,
|
||||
}
|
||||
pub async fn set_key(
|
||||
State(app): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<KeyInput>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let name = authorize(&app, &headers, true, false).await?;
|
||||
let key = public_key(&input.public_key).map_err(bad)?;
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
if accounts
|
||||
.grants
|
||||
.publishers
|
||||
.iter()
|
||||
.any(|(user, grant)| user != &name && grant.ssh_key.as_deref() == Some(&key))
|
||||
{
|
||||
return Err(bad("公钥已被其他用户绑定"));
|
||||
}
|
||||
let mut grants = accounts.grants.clone();
|
||||
grants
|
||||
.publishers
|
||||
.get_mut(&name)
|
||||
.ok_or_else(|| bad("用户不存在"))?
|
||||
.ssh_key = Some(key);
|
||||
atomic(
|
||||
&app.ssh_config,
|
||||
&serde_json::to_vec_pretty(&grants).map_err(internal)?,
|
||||
0o644,
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
accounts.grants = grants;
|
||||
tracing::info!(action = "ssh_key_changed", user = %name);
|
||||
Ok(Json(
|
||||
json!({"status": "saved", "message": "密钥已保存,发布仍需管理员授权"}),
|
||||
))
|
||||
}
|
||||
pub async fn users(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Json<Value>> {
|
||||
authorize(&app, &headers, false, true).await?;
|
||||
Ok(Json(json!(app.accounts.lock().await.grants.publishers)))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GrantInput {
|
||||
enabled: bool,
|
||||
role: Role,
|
||||
prefixes: Vec<String>,
|
||||
}
|
||||
pub async fn grant(
|
||||
State(app): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
Path(name): Path<String>,
|
||||
Json(input): Json<GrantInput>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let actor = authorize(&app, &headers, true, true).await?;
|
||||
ensure_prefixes(&input.prefixes).map_err(bad)?;
|
||||
if input.role == Role::Publisher && input.prefixes.is_empty() {
|
||||
return Err(bad("发布者必须分配文章前缀"));
|
||||
}
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
if !accounts
|
||||
.grants
|
||||
.publishers
|
||||
.get(&actor)
|
||||
.is_some_and(|g| g.enabled && g.role == Role::Admin)
|
||||
{
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "管理员权限已失效".into()));
|
||||
}
|
||||
if !accounts.credentials.contains_key(&name) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "用户不存在".into()));
|
||||
}
|
||||
if actor == name && (input.role != Role::Admin || !input.enabled) {
|
||||
return Err(bad("不能取消自己的管理权限或停用自己"));
|
||||
}
|
||||
for (other, grant) in &accounts.grants.publishers {
|
||||
if other == &name || !grant.enabled || grant.role == Role::Reader {
|
||||
continue;
|
||||
}
|
||||
if input.enabled
|
||||
&& input.role != Role::Reader
|
||||
&& input.prefixes.iter().any(|a| {
|
||||
grant
|
||||
.prefixes
|
||||
.iter()
|
||||
.any(|b| a.starts_with(b) || b.starts_with(a))
|
||||
})
|
||||
{
|
||||
return Err(bad("发布前缀与其他用户重叠"));
|
||||
}
|
||||
}
|
||||
let mut grants = accounts.grants.clone();
|
||||
let entry = grants
|
||||
.publishers
|
||||
.get_mut(&name)
|
||||
.ok_or_else(|| bad("用户授权缺失"))?;
|
||||
entry.enabled = input.enabled;
|
||||
entry.role = input.role;
|
||||
entry.prefixes = if input.role == Role::Reader {
|
||||
vec![]
|
||||
} else {
|
||||
input.prefixes
|
||||
};
|
||||
atomic(
|
||||
&app.ssh_config,
|
||||
&serde_json::to_vec_pretty(&grants).map_err(internal)?,
|
||||
0o644,
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
accounts.grants = grants;
|
||||
accounts
|
||||
.sessions
|
||||
.retain(|_, s| s.username != name || s.username == actor);
|
||||
tracing::info!(action = "permission_changed", user = %name, administrator = %actor);
|
||||
Ok(Json(json!({"status": "saved"})))
|
||||
}
|
||||
fn ensure_prefixes(prefixes: &[String]) -> Result<()> {
|
||||
ensure!(
|
||||
prefixes.len() <= 8
|
||||
&& prefixes.iter().all(|p| (2..=64).contains(&p.len())
|
||||
&& p.ends_with('-')
|
||||
&& p.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')),
|
||||
"前缀必须为 2–64 位小写英文、数字、短横线且以短横线结尾,最多 8 个"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasswordInput {
|
||||
old_password: String,
|
||||
new_password: String,
|
||||
}
|
||||
pub async fn change_password(
|
||||
State(app): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<PasswordInput>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let name = authorize(&app, &headers, true, false).await?;
|
||||
password(&input.old_password).map_err(bad)?;
|
||||
password(&input.new_password).map_err(bad)?;
|
||||
let _permit = app
|
||||
.hash_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?;
|
||||
let previous = app
|
||||
.accounts
|
||||
.lock()
|
||||
.await
|
||||
.credentials
|
||||
.get(&name)
|
||||
.ok_or_else(|| bad("用户不存在"))?
|
||||
.hash
|
||||
.clone();
|
||||
let hash = previous.clone();
|
||||
let verified = tokio::task::spawn_blocking(move || {
|
||||
PasswordHash::new(&hash).ok().is_some_and(|h| {
|
||||
Argon2::default()
|
||||
.verify_password(input.old_password.as_bytes(), &h)
|
||||
.is_ok()
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
if !verified {
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "旧口令错误".into()));
|
||||
}
|
||||
let hash = hash_password(input.new_password).await.map_err(internal)?;
|
||||
let mut accounts = app.accounts.lock().await;
|
||||
if !accounts
|
||||
.grants
|
||||
.publishers
|
||||
.get(&name)
|
||||
.is_some_and(|g| g.enabled)
|
||||
{
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "用户已停用".into()));
|
||||
}
|
||||
let credential = accounts
|
||||
.credentials
|
||||
.get_mut(&name)
|
||||
.ok_or_else(|| bad("用户不存在"))?;
|
||||
if credential.hash != previous {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"口令已变更,请重新登录".into(),
|
||||
));
|
||||
}
|
||||
credential.hash = hash;
|
||||
if let Err(error) = atomic(
|
||||
&app.data.join("users.json"),
|
||||
&serde_json::to_vec(&accounts.credentials).map_err(internal)?,
|
||||
0o600,
|
||||
)
|
||||
.await
|
||||
{
|
||||
accounts.credentials.get_mut(&name).unwrap().hash = previous;
|
||||
return Err(internal(error));
|
||||
}
|
||||
accounts.sessions.retain(|_, s| s.username != name);
|
||||
tracing::info!(action = "password_changed", user = %name);
|
||||
Ok(Json(
|
||||
json!({"status": "saved", "message": "请使用新口令重新登录"}),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
pub const MAX_MARKDOWN: usize = 256 * 1024;
|
||||
pub const MAX_SNAPSHOT: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Status {
|
||||
Draft,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Metadata {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub date: String,
|
||||
pub updated: String,
|
||||
pub status: Status,
|
||||
pub tags: Vec<String>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Asset {
|
||||
pub name: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Post {
|
||||
pub meta: Metadata,
|
||||
pub markdown: String,
|
||||
pub assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
pub struct Snapshot {
|
||||
pub commit: String,
|
||||
pub posts: BTreeMap<String, Post>,
|
||||
}
|
||||
|
||||
pub fn slug(value: &str) -> Result<&str> {
|
||||
ensure!(
|
||||
(1..=80).contains(&value.len())
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
|
||||
&& !value.starts_with('-')
|
||||
&& !value.ends_with('-'),
|
||||
"slug 仅允许 1–80 位小写英文、数字和中间短横线"
|
||||
);
|
||||
ensure!(
|
||||
![
|
||||
"admin", "api", "posts", "tags", "archive", "rss", "sitemap", "404"
|
||||
]
|
||||
.contains(&value),
|
||||
"slug 为保留名称"
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn parse(markdown: String, assets: Vec<Asset>) -> Result<Post> {
|
||||
ensure!(markdown.len() <= MAX_MARKDOWN, "Markdown 超过 256 KiB");
|
||||
let normalized = markdown.replace("\r\n", "\n");
|
||||
let rest = normalized
|
||||
.strip_prefix("---\n")
|
||||
.context("缺少 YAML Frontmatter")?;
|
||||
let (yaml, _) = rest.split_once("\n---\n").context("Frontmatter 未闭合")?;
|
||||
let meta: Metadata = serde_yaml::from_str(yaml).context("Frontmatter 无效")?;
|
||||
slug(&meta.slug)?;
|
||||
ensure!(
|
||||
!meta.title.trim().is_empty() && meta.title.len() <= 240 && meta.description.len() <= 1000,
|
||||
"标题或摘要长度无效"
|
||||
);
|
||||
for date in [&meta.date, &meta.updated] {
|
||||
let parsed =
|
||||
NaiveDate::parse_from_str(date, "%Y-%m-%d").context("日期必须为 YYYY-MM-DD")?;
|
||||
ensure!(
|
||||
parsed.format("%Y-%m-%d").to_string() == *date,
|
||||
"日期格式不规范"
|
||||
);
|
||||
}
|
||||
ensure!(meta.updated >= meta.date, "更新时间不能早于发布日期");
|
||||
ensure!(
|
||||
meta.tags.len() <= 12
|
||||
&& meta
|
||||
.tags
|
||||
.iter()
|
||||
.all(|t| !t.trim().is_empty() && t.len() <= 80),
|
||||
"标签无效"
|
||||
);
|
||||
ensure!(assets.len() <= 12, "最多 12 张图片");
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let mut total = normalized.len();
|
||||
for asset in &assets {
|
||||
ensure!(names.insert(&asset.name), "图片名称重复");
|
||||
let bytes = decode_asset(asset)?;
|
||||
total += bytes.len();
|
||||
}
|
||||
ensure!(total <= 6 * 1024 * 1024, "文章及图片总计不得超过 6 MiB");
|
||||
validate_markdown(rest.split_once("\n---\n").unwrap().1, &assets)?;
|
||||
Ok(Post {
|
||||
meta,
|
||||
markdown: normalized,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_asset(asset: &Asset) -> Result<Vec<u8>> {
|
||||
let name = &asset.name;
|
||||
ensure!(
|
||||
name.len() <= 100
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains("..")
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||
&& name != "index.md",
|
||||
"图片文件名无效"
|
||||
);
|
||||
ensure!(asset.data.len() <= 3 * 1024 * 1024, "图片超过大小限制");
|
||||
let bytes = STANDARD.decode(&asset.data).context("图片编码无效")?;
|
||||
ensure!(bytes.len() <= 2 * 1024 * 1024, "单张图片不得超过 2 MiB");
|
||||
let format = image::guess_format(&bytes).context("无法识别图片")?;
|
||||
let extension = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
ensure!(
|
||||
matches!(
|
||||
(format, extension.as_str()),
|
||||
(image::ImageFormat::Png, "png")
|
||||
| (image::ImageFormat::Jpeg, "jpg" | "jpeg")
|
||||
| (image::ImageFormat::WebP, "webp")
|
||||
),
|
||||
"仅支持与扩展名一致的 PNG/JPEG/WebP"
|
||||
);
|
||||
let reader = image::ImageReader::with_format(std::io::Cursor::new(&bytes), format);
|
||||
let (width, height) = reader.into_dimensions().context("图片头无效")?;
|
||||
ensure!(
|
||||
width > 0 && height > 0 && width <= 4096 && height <= 4096,
|
||||
"图片尺寸不得超过 4096×4096"
|
||||
);
|
||||
image::load_from_memory_with_format(&bytes, format).context("图片内容损坏")?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub async fn git(repo: &Path, args: &[&str], input: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.arg("--no-pager")
|
||||
.arg("-c")
|
||||
.arg("core.hooksPath=/dev/null")
|
||||
.arg("-c")
|
||||
.arg("commit.gpgsign=false")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/etc/chublog/gitconfig")
|
||||
.env("GIT_AUTHOR_NAME", "ChuBlog")
|
||||
.env("GIT_AUTHOR_EMAIL", "blog@localhost")
|
||||
.env("GIT_COMMITTER_NAME", "ChuBlog")
|
||||
.env("GIT_COMMITTER_EMAIL", "blog@localhost")
|
||||
.env("GIT_INDEX_FILE", repo.join("publish.index"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
let mut child = command.spawn().context("启动 Git 失败")?;
|
||||
let mut stdin = child.stdin.take().context("Git stdin")?;
|
||||
let stdout = child.stdout.take().context("Git stdout")?;
|
||||
let stderr = child.stderr.take().context("Git stderr")?;
|
||||
let write = async {
|
||||
stdin.write_all(input.unwrap_or_default()).await?;
|
||||
drop(stdin);
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
let read_out = async {
|
||||
let mut out = Vec::new();
|
||||
stdout
|
||||
.take(4 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut out)
|
||||
.await?;
|
||||
Ok::<_, std::io::Error>(out)
|
||||
};
|
||||
let read_err = async {
|
||||
let mut err = Vec::new();
|
||||
stderr.take(65537).read_to_end(&mut err).await?;
|
||||
Ok::<_, std::io::Error>(err)
|
||||
};
|
||||
let (_, stdout, stderr) = tokio::try_join!(write, read_out, read_err)?;
|
||||
ensure!(
|
||||
stdout.len() <= 4 * 1024 * 1024 && stderr.len() <= 65536,
|
||||
"Git 输出超过限制"
|
||||
);
|
||||
let status = child.wait().await?;
|
||||
Ok::<_, anyhow::Error>(std::process::Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.context("Git 操作超时")??;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"Git 操作失败: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
pub async fn init(data: &Path) -> Result<PathBuf> {
|
||||
tokio::fs::create_dir_all(data).await?;
|
||||
let repo = data.join("content.git");
|
||||
if !repo.exists() {
|
||||
tokio::fs::create_dir(&repo).await?;
|
||||
git(
|
||||
&repo,
|
||||
&["init", "--bare", "--initial-branch=main", "."],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
git(&repo, &["read-tree", "--empty"], None).await?;
|
||||
let tree = git(&repo, &["write-tree"], None).await?;
|
||||
let tree = String::from_utf8(tree)?;
|
||||
let commit = git(
|
||||
&repo,
|
||||
&[
|
||||
"commit-tree",
|
||||
tree.trim(),
|
||||
"-m",
|
||||
"Initialize content repository",
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let commit = String::from_utf8(commit)?;
|
||||
git(
|
||||
&repo,
|
||||
&["update-ref", "refs/heads/main", commit.trim()],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
pub async fn scan(repo: &Path) -> Result<Snapshot> {
|
||||
let commit = String::from_utf8(git(repo, &["rev-parse", "refs/heads/main"], None).await?)?
|
||||
.trim()
|
||||
.to_owned();
|
||||
scan_at(repo, &commit).await
|
||||
}
|
||||
|
||||
pub async fn scan_at(repo: &Path, commit: &str) -> Result<Snapshot> {
|
||||
let commit = commit.to_owned();
|
||||
let entries = git(repo, &["ls-tree", "-rz", &commit], None).await?;
|
||||
let mut grouped: BTreeMap<String, (Option<String>, Vec<Asset>)> = BTreeMap::new();
|
||||
let mut total = 0;
|
||||
for entry in entries.split(|b| *b == 0).filter(|e| !e.is_empty()) {
|
||||
let entry = std::str::from_utf8(entry)?;
|
||||
let (info, path) = entry.split_once('\t').context("Git 树格式错误")?;
|
||||
let info: Vec<_> = info.split_whitespace().collect();
|
||||
ensure!(
|
||||
info.len() == 3 && info[0] == "100644" && info[1] == "blob",
|
||||
"禁止符号链接及特殊文件"
|
||||
);
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
ensure!(
|
||||
parts.len() == 3 && parts[0] == "posts",
|
||||
"仓库包含非文章路径"
|
||||
);
|
||||
slug(parts[1])?;
|
||||
let size = String::from_utf8(git(repo, &["cat-file", "-s", info[2]], None).await?)?
|
||||
.trim()
|
||||
.parse::<usize>()?;
|
||||
let limit = if parts[2] == "index.md" {
|
||||
MAX_MARKDOWN
|
||||
} else {
|
||||
2 * 1024 * 1024
|
||||
};
|
||||
ensure!(size <= limit, "文件超过大小限制");
|
||||
let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?;
|
||||
total += bytes.len();
|
||||
ensure!(total <= MAX_SNAPSHOT, "内容仓库超过 64 MiB 配额");
|
||||
let group = grouped.entry(parts[1].into()).or_default();
|
||||
if parts[2] == "index.md" {
|
||||
group.0 = Some(String::from_utf8(bytes)?);
|
||||
} else {
|
||||
group.1.push(Asset {
|
||||
name: parts[2].into(),
|
||||
data: STANDARD.encode(bytes),
|
||||
});
|
||||
}
|
||||
ensure!(grouped.len() <= 1000, "最多 1000 篇文章");
|
||||
}
|
||||
let mut posts = BTreeMap::new();
|
||||
for (key, (markdown, assets)) in grouped {
|
||||
let post = parse(markdown.context("文章缺少 index.md")?, assets)?;
|
||||
ensure!(post.meta.slug == key, "slug 与 Git 路径不一致");
|
||||
posts.insert(key, post);
|
||||
}
|
||||
Ok(Snapshot { commit, posts })
|
||||
}
|
||||
|
||||
pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> {
|
||||
let public = Snapshot {
|
||||
commit: snapshot.commit.clone(),
|
||||
posts: snapshot
|
||||
.posts
|
||||
.iter()
|
||||
.filter(|(_, p)| p.meta.status == Status::Published)
|
||||
.map(|(k, p)| (k.clone(), p.clone()))
|
||||
.collect(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&public)?;
|
||||
let tmp = data.join("published.json.tmp");
|
||||
let mut file = tokio::fs::File::create(&tmp).await?;
|
||||
file.write_all(&bytes).await?;
|
||||
file.sync_all().await?;
|
||||
tokio::fs::rename(tmp, data.join("published.json")).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> {
|
||||
use pulldown_cmark::{Event, Parser, Tag};
|
||||
ensure!(
|
||||
!body.chars().any(|c| c == '\0'
|
||||
|| (c.is_control() && !"\n\r\t".contains(c))
|
||||
|| matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')),
|
||||
"禁止控制符及双向隐藏字符"
|
||||
);
|
||||
for event in Parser::new_ext(body, pulldown_cmark::Options::all()) {
|
||||
match event {
|
||||
Event::Html(_) | Event::InlineHtml(_) => anyhow::bail!("Markdown 不允许原始 HTML"),
|
||||
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||
ensure!(
|
||||
dest_url.starts_with("https://")
|
||||
|| dest_url.starts_with("http://")
|
||||
|| dest_url.starts_with("mailto:")
|
||||
|| dest_url.starts_with('#')
|
||||
|| (dest_url.starts_with('/') && !dest_url.starts_with("//")),
|
||||
"链接协议不允许"
|
||||
);
|
||||
ensure!(
|
||||
!dest_url.contains('\\') && !dest_url.chars().any(char::is_control),
|
||||
"链接含非法字符"
|
||||
);
|
||||
}
|
||||
Event::Start(Tag::Image { dest_url, .. }) => ensure!(
|
||||
assets.iter().any(|a| a.name == dest_url.as_ref()),
|
||||
"图片必须引用同文章已验证的图片文件"
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use crate::content::{Snapshot, Status};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use chusql_driver::{Client, Options, Query};
|
||||
use std::time::Duration;
|
||||
|
||||
async fn connect() -> Result<Client> {
|
||||
let database = std::env::var("CHUSQL_DATABASE").context("未配置 ChuSQL")?;
|
||||
ensure!(
|
||||
!database.is_empty()
|
||||
&& !database.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& database
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_'),
|
||||
"数据库名称无效"
|
||||
);
|
||||
let db = Client::connect_with_options(
|
||||
std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into()),
|
||||
&std::env::var("CHUSQL_USER")?,
|
||||
&std::env::var("CHUSQL_PASSWORD")?,
|
||||
Options {
|
||||
timeout: Duration::from_secs(3),
|
||||
max_response_bytes: 1024 * 1024,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
db.query(&format!("USE {database}")).await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
|
||||
let db = connect().await?;
|
||||
// Probe before creating: ChuSQL does not promise IF NOT EXISTS syntax.
|
||||
if db
|
||||
.query("SELECT slug FROM blog_posts LIMIT 1")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
db.query("CREATE TABLE blog_posts (slug TEXT PRIMARY KEY, title TEXT, description TEXT, published_at TEXT, updated_at TEXT, path TEXT, git_commit TEXT)").await?;
|
||||
}
|
||||
if db
|
||||
.query("SELECT slug FROM blog_tags LIMIT 1")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)")
|
||||
.await?;
|
||||
}
|
||||
db.query("DELETE FROM blog_tags").await?;
|
||||
db.query("DELETE FROM blog_posts").await?;
|
||||
for post in snapshot
|
||||
.posts
|
||||
.values()
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
{
|
||||
let meta = &post.meta;
|
||||
let q = Query::new("INSERT INTO blog_posts (slug, title, description, published_at, updated_at, path, git_commit) VALUES (")
|
||||
.bind(meta.slug.as_str()).push(", ").bind(meta.title.as_str()).push(", ")
|
||||
.bind(meta.description.as_str()).push(", ").bind(meta.date.as_str()).push(", ")
|
||||
.bind(meta.updated.as_str()).push(", ").bind(format!("posts/{}/index.md", meta.slug).as_str())
|
||||
.push(", ").bind(snapshot.commit.as_str()).push(")");
|
||||
db.query(q.as_str()).await?;
|
||||
for tag in &meta.tags {
|
||||
let q = Query::new("INSERT INTO blog_tags (slug, tag, git_commit) VALUES (")
|
||||
.bind(meta.slug.as_str())
|
||||
.push(", ")
|
||||
.bind(tag.as_str())
|
||||
.push(", ")
|
||||
.bind(snapshot.commit.as_str())
|
||||
.push(")");
|
||||
db.query(q.as_str()).await?;
|
||||
}
|
||||
}
|
||||
db.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn provision() -> Result<()> {
|
||||
let db = Client::connect(
|
||||
std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into()),
|
||||
"root",
|
||||
&std::env::var("CHUSQL_ROOT_PASSWORD")?,
|
||||
)
|
||||
.await?;
|
||||
if db.query("USE chublog").await.is_err() {
|
||||
db.query("CREATE DATABASE chublog").await?;
|
||||
}
|
||||
let roles = db.query("SHOW ROLES").await?;
|
||||
ensure!(!roles.truncated, "身份列表被截断,请人工检查数据库初始化");
|
||||
if !roles
|
||||
.rows
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|v| v.as_str() == Some("chublog_index"))
|
||||
{
|
||||
let password = std::env::var("CHUSQL_PASSWORD")?;
|
||||
ensure!(password.len() >= 16, "数据库口令至少 16 字节");
|
||||
let q = Query::new("CREATE USER chublog_index IDENTIFIED BY ").bind(password.as_str());
|
||||
db.query(q.as_str())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("创建索引用户失败"))?;
|
||||
}
|
||||
db.query("REVOKE CONNECT ON DATABASE chublog FROM PUBLIC")
|
||||
.await?;
|
||||
db.query("GRANT CONNECT, CREATE ON DATABASE chublog TO chublog_index")
|
||||
.await?;
|
||||
db.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Detect a lost/recreated index even if no new article was pushed.
|
||||
pub async fn consistent(snapshot: &Snapshot) -> Result<bool> {
|
||||
let db = connect().await?;
|
||||
let result = db
|
||||
.query("SELECT slug, git_commit FROM blog_posts LIMIT 1000")
|
||||
.await?;
|
||||
let expected: std::collections::BTreeSet<_> = snapshot
|
||||
.posts
|
||||
.values()
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
.map(|p| (p.meta.slug.as_str(), snapshot.commit.as_str()))
|
||||
.collect();
|
||||
let actual: std::collections::BTreeSet<_> = result
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
|
||||
.collect();
|
||||
let count = db.query("SELECT count(*) FROM blog_tags").await?;
|
||||
let tags = snapshot
|
||||
.posts
|
||||
.values()
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
.map(|p| p.meta.tags.len())
|
||||
.sum::<usize>() as u64;
|
||||
let matches = !result.truncated
|
||||
&& result.rows.len() == expected.len()
|
||||
&& actual == expected
|
||||
&& count
|
||||
.rows
|
||||
.first()
|
||||
.and_then(|row| row.first())
|
||||
.and_then(|v| v.as_u64())
|
||||
== Some(tags);
|
||||
db.close().await;
|
||||
Ok(matches)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
mod accounts;
|
||||
mod content;
|
||||
mod index;
|
||||
mod ssh;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, Path, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, patch, post, put},
|
||||
};
|
||||
use base64::Engine;
|
||||
use content::{Snapshot, Status};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::{Mutex, RwLock, Semaphore};
|
||||
use tracing::{info, warn};
|
||||
|
||||
type ApiResult<T> = std::result::Result<T, ApiError>;
|
||||
struct ApiError(StatusCode, String);
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(json!({"error": self.1}))).into_response()
|
||||
}
|
||||
}
|
||||
fn bad(error: impl std::fmt::Display) -> ApiError {
|
||||
ApiError(StatusCode::BAD_REQUEST, error.to_string())
|
||||
}
|
||||
fn internal(error: impl std::fmt::Display) -> ApiError {
|
||||
tracing::error!(error = %error, "operation failed");
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"服务器操作失败,请联系管理员".into(),
|
||||
)
|
||||
}
|
||||
struct App {
|
||||
data: PathBuf,
|
||||
ssh_config: PathBuf,
|
||||
origin: String,
|
||||
secure: bool,
|
||||
registration: bool,
|
||||
accounts: Mutex<accounts::Accounts>,
|
||||
hash_slots: Arc<Semaphore>,
|
||||
snapshot: RwLock<Snapshot>,
|
||||
indexed: RwLock<Option<String>>,
|
||||
views: AtomicU64,
|
||||
}
|
||||
fn origin(app: &App, headers: &HeaderMap) -> ApiResult<()> {
|
||||
if headers.get("origin").and_then(|h| h.to_str().ok()) != Some(app.origin.as_str()) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "Origin 校验失败".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn posts(State(app): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = app.snapshot.read().await;
|
||||
let mut posts: Vec<_> = snapshot
|
||||
.posts
|
||||
.values()
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
.map(|p| &p.meta)
|
||||
.collect();
|
||||
posts.sort_by(|a, b| b.date.cmp(&a.date).then(a.slug.cmp(&b.slug)));
|
||||
Json(json!({"commit": snapshot.commit, "posts": posts}))
|
||||
}
|
||||
async fn public_post(
|
||||
State(app): State<Arc<App>>,
|
||||
Path(key): Path<String>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let snapshot = app.snapshot.read().await;
|
||||
let post = snapshot
|
||||
.posts
|
||||
.get(&key)
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "文章不存在".into()))?;
|
||||
// Relaxed is sufficient: this independent counter does not synchronize other state.
|
||||
app.views.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(Json(
|
||||
json!({"meta": post.meta, "markdown": post.markdown, "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::<Vec<_>>()}),
|
||||
))
|
||||
}
|
||||
async fn asset(
|
||||
State(app): State<Arc<App>>,
|
||||
Path((key, name)): Path<(String, String)>,
|
||||
) -> ApiResult<Response> {
|
||||
let snapshot = app.snapshot.read().await;
|
||||
let asset = snapshot
|
||||
.posts
|
||||
.get(&key)
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
.and_then(|p| p.assets.iter().find(|a| a.name == name))
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "资源不存在".into()))?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&asset.data)
|
||||
.map_err(internal)?;
|
||||
let mime = match name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
_ => return Err(internal("invalid stored image")),
|
||||
};
|
||||
Ok(([(header::CONTENT_TYPE, mime)], Body::from(bytes)).into_response())
|
||||
}
|
||||
async fn stats(State(app): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = app.snapshot.read().await;
|
||||
let posts: Vec<_> = snapshot
|
||||
.posts
|
||||
.values()
|
||||
.filter(|p| p.meta.status == Status::Published)
|
||||
.collect();
|
||||
let tags: std::collections::BTreeSet<_> = posts.iter().flat_map(|p| &p.meta.tags).collect();
|
||||
Json(
|
||||
json!({"published_posts": posts.len(), "tags": tags.len(), "markdown_bytes": posts.iter().map(|p| p.markdown.len()).sum::<usize>(), "article_requests_since_start": app.views.load(Ordering::Relaxed)}),
|
||||
)
|
||||
}
|
||||
async fn admin_status(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Json<Value>> {
|
||||
accounts::authorize(&app, &headers, false, true).await?;
|
||||
let snapshot = app.snapshot.read().await;
|
||||
Ok(Json(
|
||||
json!({"commit": snapshot.commit, "total_posts": snapshot.posts.len(), "drafts": snapshot.posts.values().filter(|p| p.meta.status == Status::Draft).count(), "indexed_commit": *app.indexed.read().await}),
|
||||
))
|
||||
}
|
||||
async fn sync_index(app: &App, snapshot: &Snapshot) {
|
||||
match tokio::time::timeout(Duration::from_secs(30), index::rebuild(snapshot)).await {
|
||||
Ok(Ok(())) => {
|
||||
*app.indexed.write().await = Some(snapshot.commit.clone());
|
||||
info!(action = "index_synced", commit = %snapshot.commit);
|
||||
}
|
||||
Ok(Err(error)) => warn!(action = "index_pending", error = %error),
|
||||
Err(_) => warn!(action = "index_pending", "rebuild timeout"),
|
||||
}
|
||||
}
|
||||
fn router(app: Arc<App>) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/health",
|
||||
get(|| async { Json(json!({"status": "ok"})) }),
|
||||
)
|
||||
.route("/api/posts", get(posts))
|
||||
.route("/api/posts/{slug}", get(public_post))
|
||||
.route("/api/assets/{slug}/{name}", get(asset))
|
||||
.route("/api/stats", get(stats))
|
||||
.route("/api/auth/register", post(accounts::register))
|
||||
.route("/api/auth/login", post(accounts::login))
|
||||
.route("/api/auth/logout", post(accounts::logout))
|
||||
.route("/api/account", get(accounts::me))
|
||||
.route("/api/account/ssh-key", put(accounts::set_key))
|
||||
.route("/api/account/password", put(accounts::change_password))
|
||||
.route("/api/admin/users", get(accounts::users))
|
||||
.route("/api/admin/users/{username}", patch(accounts::grant))
|
||||
.route("/api/admin/status", get(admin_status))
|
||||
.layer(DefaultBodyLimit::max(8 * 1024))
|
||||
.layer(axum::middleware::from_fn(
|
||||
|request: axum::extract::Request, next: axum::middleware::Next| async move {
|
||||
let mut response = next.run(request).await;
|
||||
for (key, value) in [
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'none'; frame-ancestors 'none'",
|
||||
),
|
||||
(header::REFERRER_POLICY, "no-referrer"),
|
||||
] {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(key, HeaderValue::from_static(value));
|
||||
}
|
||||
response
|
||||
},
|
||||
))
|
||||
.with_state(app)
|
||||
}
|
||||
async fn refresh(app: &App, repo: &std::path::Path, source: &std::path::Path) -> Result<()> {
|
||||
// Recover a previously committed ref even if snapshot publication was interrupted.
|
||||
let mut current = app.snapshot.read().await.clone();
|
||||
let head =
|
||||
String::from_utf8(content::git(repo, &["rev-parse", "refs/heads/main"], None).await?)?;
|
||||
if current.commit != head.trim() {
|
||||
current = content::scan(repo).await?;
|
||||
}
|
||||
let snapshot = ssh::import(repo, source, ¤t).await?;
|
||||
if snapshot.commit != app.snapshot.read().await.commit {
|
||||
content::persist(&app.data, &snapshot).await?;
|
||||
info!(action = "published", commit = %snapshot.commit);
|
||||
*app.snapshot.write().await = snapshot;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
let command = std::env::args().nth(1);
|
||||
match command.as_deref() {
|
||||
Some("ssh-gate") => {
|
||||
return ssh::gate(&std::env::args().nth(2).context("缺少发布者身份")?).await;
|
||||
}
|
||||
Some("pre-receive") => return ssh::pre_receive().await,
|
||||
Some("authorized-keys") => return ssh::authorized_keys(),
|
||||
Some("provision-db") => return index::provision().await,
|
||||
Some("hash-password") => {
|
||||
use std::io::Read;
|
||||
let mut value = String::new();
|
||||
std::io::stdin().take(1025).read_to_string(&mut value)?;
|
||||
println!(
|
||||
"{}",
|
||||
accounts::hash_password(value.trim_end_matches(['\r', '\n']).into()).await?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
None | Some("rebuild-index") => {}
|
||||
Some(_) => anyhow::bail!("未知命令"),
|
||||
}
|
||||
let data = PathBuf::from(std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into()));
|
||||
tokio::fs::create_dir_all(&data).await?;
|
||||
let data = tokio::fs::canonicalize(data).await?;
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(data.join("writer.lock"))?;
|
||||
fs2::FileExt::try_lock_exclusive(&lock_file).context("内容目录已被另一个 API 进程使用")?;
|
||||
let repo = content::init(&data).await?;
|
||||
let snapshot = content::scan(&repo).await?;
|
||||
content::persist(&data, &snapshot).await?;
|
||||
if command.as_deref() == Some("rebuild-index") {
|
||||
index::rebuild(&snapshot).await?;
|
||||
return Ok(());
|
||||
}
|
||||
let origin = std::env::var("PUBLIC_ORIGIN").context("必须设置 PUBLIC_ORIGIN")?;
|
||||
let parsed: axum::http::Uri = origin.parse().context("PUBLIC_ORIGIN 无效")?;
|
||||
let secure = parsed.scheme_str() == Some("https");
|
||||
ensure!(
|
||||
secure
|
||||
|| (parsed.scheme_str() == Some("http")
|
||||
&& matches!(parsed.host(), Some("localhost" | "127.0.0.1"))),
|
||||
"生产环境必须使用 HTTPS"
|
||||
);
|
||||
ensure!(
|
||||
!origin.ends_with('/')
|
||||
&& parsed.path() == "/"
|
||||
&& parsed.query().is_none()
|
||||
&& parsed
|
||||
.authority()
|
||||
.is_some_and(|a| !a.as_str().contains('@')),
|
||||
"PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin"
|
||||
);
|
||||
let source = PathBuf::from(
|
||||
std::env::var("INGRESS_REPO").unwrap_or_else(|_| "/srv/ingress/content.git".into()),
|
||||
);
|
||||
ensure!(source.is_absolute(), "INGRESS_REPO 必须是绝对路径");
|
||||
let ssh_config = PathBuf::from(
|
||||
std::env::var("CHUBLOG_SSH_CONFIG")
|
||||
.unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()),
|
||||
);
|
||||
let accounts = accounts::load(&data, &ssh_config, source.clone()).await?;
|
||||
ensure!(
|
||||
accounts.grants.repository == source,
|
||||
"授权配置仓库与 INGRESS_REPO 不一致"
|
||||
);
|
||||
let app = Arc::new(App {
|
||||
data,
|
||||
ssh_config,
|
||||
origin,
|
||||
secure,
|
||||
registration: std::env::var("ALLOW_REGISTRATION").as_deref() == Ok("true"),
|
||||
accounts: Mutex::new(accounts),
|
||||
hash_slots: Arc::new(Semaphore::new(2)),
|
||||
snapshot: RwLock::new(snapshot),
|
||||
indexed: RwLock::new(None),
|
||||
views: AtomicU64::new(0),
|
||||
});
|
||||
let worker_app = app.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
let mut ticks = 0;
|
||||
loop {
|
||||
if let Err(error) = refresh(&worker_app, &repo, &source).await {
|
||||
warn!(action = "import_pending", error = %error);
|
||||
}
|
||||
if ticks % 30 == 0 {
|
||||
let snapshot = worker_app.snapshot.read().await.clone();
|
||||
if worker_app.indexed.read().await.as_deref() != Some(snapshot.commit.as_str()) {
|
||||
sync_index(&worker_app, &snapshot).await;
|
||||
} else if !matches!(
|
||||
tokio::time::timeout(Duration::from_secs(10), index::consistent(&snapshot))
|
||||
.await,
|
||||
Ok(Ok(true))
|
||||
) {
|
||||
*worker_app.indexed.write().await = None;
|
||||
sync_index(&worker_app, &snapshot).await;
|
||||
}
|
||||
}
|
||||
ticks += 1;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
});
|
||||
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3001".into());
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
info!(%bind, "ChuBlog API ready");
|
||||
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
axum::serve(listener, router(app))
|
||||
.with_graceful_shutdown(async move {
|
||||
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
|
||||
})
|
||||
.await?;
|
||||
worker.abort();
|
||||
let _ = worker.await;
|
||||
drop(lock_file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,274 @@
|
||||
use crate::accounts::{Grant, Role};
|
||||
use crate::content::{self, Snapshot};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{io::AsyncReadExt, process::Command};
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub repository: PathBuf,
|
||||
pub publishers: BTreeMap<String, Grant>,
|
||||
}
|
||||
|
||||
pub fn config(path: &Path) -> Result<Config> {
|
||||
let meta = std::fs::symlink_metadata(path)?;
|
||||
ensure!(
|
||||
meta.is_file() && (meta.uid() == 0 || meta.uid() == 10001) && meta.mode() & 0o022 == 0,
|
||||
"SSH 授权配置必须为 root 或 API UID 10001 所有且不可被组或其他用户写入"
|
||||
);
|
||||
let bytes = std::fs::read(path)?;
|
||||
ensure!(bytes.len() < 65536, "SSH 配置过大");
|
||||
let cfg: Config = serde_json::from_slice(&bytes)?;
|
||||
ensure!(cfg.repository.is_absolute(), "接收仓库必须为绝对路径");
|
||||
Ok(cfg)
|
||||
}
|
||||
fn authorized<'a>(cfg: &'a Config, identity: &str) -> Result<&'a Grant> {
|
||||
ensure!(
|
||||
!identity.is_empty()
|
||||
&& identity.len() <= 64
|
||||
&& identity
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)),
|
||||
"发布者身份无效"
|
||||
);
|
||||
let publisher = cfg
|
||||
.publishers
|
||||
.get(identity)
|
||||
.filter(|p| p.enabled && p.role != Role::Reader && p.ssh_key.is_some())
|
||||
.context("服务器未授权此身份发布文章")?;
|
||||
ensure!(
|
||||
!publisher.prefixes.is_empty()
|
||||
&& publisher.prefixes.iter().all(|p| !p.is_empty()
|
||||
&& p.len() <= 64
|
||||
&& p.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')),
|
||||
"发布者必须拥有明确 slug 前缀"
|
||||
);
|
||||
Ok(publisher)
|
||||
}
|
||||
fn directory_size(path: &Path) -> Result<u64> {
|
||||
let mut total = 0u64;
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let meta = entry.file_type()?;
|
||||
ensure!(!meta.is_symlink(), "接收仓库不得包含符号链接");
|
||||
total = total.saturating_add(if meta.is_dir() {
|
||||
directory_size(&entry.path())?
|
||||
} else {
|
||||
entry.metadata()?.len()
|
||||
});
|
||||
ensure!(
|
||||
total <= 256 * 1024 * 1024,
|
||||
"接收仓库达到 256 MiB 配额,请管理员维护"
|
||||
);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub async fn gate(identity: &str) -> Result<()> {
|
||||
let config_path = PathBuf::from(
|
||||
std::env::var("CHUBLOG_SSH_CONFIG").unwrap_or_else(|_| "/auth/publishers.json".into()),
|
||||
);
|
||||
let cfg = config(&config_path)?;
|
||||
authorized(&cfg, identity)?;
|
||||
let original = std::env::var("SSH_ORIGINAL_COMMAND").unwrap_or_default();
|
||||
ensure!(
|
||||
matches!(
|
||||
original.as_str(),
|
||||
"git-receive-pack 'content.git'" | "git-upload-pack 'content.git'"
|
||||
),
|
||||
"仅允许对 content.git 执行 Git 推送或读取"
|
||||
);
|
||||
let receiving = original.starts_with("git-receive-pack");
|
||||
directory_size(&cfg.repository)?;
|
||||
let lock = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(cfg.repository.join("ssh.lock"))?;
|
||||
fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?;
|
||||
let executable = if receiving {
|
||||
"git-receive-pack"
|
||||
} else {
|
||||
"git-upload-pack"
|
||||
};
|
||||
let mut child = Command::new(executable)
|
||||
.arg(&cfg.repository)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", "/nonexistent")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("CHUBLOG_SSH_CONFIG", &config_path)
|
||||
.env("CHUBLOG_PUBLISHER", identity)
|
||||
.env("GIT_CONFIG_COUNT", "5")
|
||||
.env("GIT_CONFIG_KEY_0", "receive.maxInputSize")
|
||||
.env("GIT_CONFIG_VALUE_0", "8388608")
|
||||
.env("GIT_CONFIG_KEY_1", "receive.fsckObjects")
|
||||
.env("GIT_CONFIG_VALUE_1", "true")
|
||||
.env("GIT_CONFIG_KEY_2", "transfer.fsckObjects")
|
||||
.env("GIT_CONFIG_VALUE_2", "true")
|
||||
.env("GIT_CONFIG_KEY_3", "gc.auto")
|
||||
.env("GIT_CONFIG_VALUE_3", "0")
|
||||
.env("GIT_CONFIG_KEY_4", "core.hooksPath")
|
||||
.env("GIT_CONFIG_VALUE_4", "/hooks")
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("启动受限 Git 服务失败")?;
|
||||
let status = match tokio::time::timeout(Duration::from_secs(90), child.wait()).await {
|
||||
Ok(status) => status?,
|
||||
Err(_) => {
|
||||
child.kill().await?;
|
||||
bail!("SSH Git 会话超过 90 秒限制");
|
||||
}
|
||||
};
|
||||
ensure!(status.success(), "Git 访问失败");
|
||||
drop(lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_change(
|
||||
repo: &Path,
|
||||
previous: &str,
|
||||
next: &str,
|
||||
publisher: &Grant,
|
||||
) -> Result<Snapshot> {
|
||||
ensure!(
|
||||
next.len() == 40
|
||||
&& next.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
&& next != "0000000000000000000000000000000000000000",
|
||||
"禁止删除主分支或无效提交"
|
||||
);
|
||||
let initial = previous == "0000000000000000000000000000000000000000";
|
||||
let parents =
|
||||
String::from_utf8(content::git(repo, &["show", "-s", "--format=%P", next], None).await?)?;
|
||||
ensure!(
|
||||
parents.trim() == if initial { "" } else { previous },
|
||||
"每次只允许推送一个直接继承主分支的提交,首次必须为根提交;不允许合并或强推"
|
||||
);
|
||||
let snapshot = content::scan_at(repo, next).await?;
|
||||
// Authorize raw Git paths: normalization must not hide edits to another author's bytes.
|
||||
let changes = if initial {
|
||||
content::git(repo, &["ls-tree", "--name-only", "-rz", next], None).await?
|
||||
} else {
|
||||
content::git(
|
||||
repo,
|
||||
&[
|
||||
"diff-tree",
|
||||
"--no-commit-id",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-r",
|
||||
"-z",
|
||||
previous,
|
||||
next,
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
for path in changes.split(|b| *b == 0).filter(|p| !p.is_empty()) {
|
||||
let path = std::str::from_utf8(path)?;
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
ensure!(parts.len() == 3 && parts[0] == "posts", "修改路径无效");
|
||||
let key = content::slug(parts[1])?;
|
||||
ensure!(
|
||||
publisher.prefixes.iter().any(|p| key.starts_with(p)),
|
||||
"无权修改文章 {key}"
|
||||
);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub async fn pre_receive() -> Result<()> {
|
||||
let config_path =
|
||||
PathBuf::from(std::env::var("CHUBLOG_SSH_CONFIG").context("缺少受控 SSH 配置")?);
|
||||
let cfg = config(&config_path)?;
|
||||
let identity = std::env::var("CHUBLOG_PUBLISHER").context("拒绝未经 SSH 网关的推送")?;
|
||||
let publisher = authorized(&cfg, &identity)?;
|
||||
let mut input = String::new();
|
||||
tokio::io::stdin()
|
||||
.take(1025)
|
||||
.read_to_string(&mut input)
|
||||
.await?;
|
||||
ensure!(input.len() <= 1024, "引用更新过多");
|
||||
let lines: Vec<_> = input.lines().collect();
|
||||
ensure!(lines.len() == 1, "每次只能更新主分支,禁止标签及其他引用");
|
||||
let fields: Vec<_> = lines[0].split_whitespace().collect();
|
||||
ensure!(
|
||||
fields.len() == 3 && fields[2] == "refs/heads/main",
|
||||
"仅允许 refs/heads/main"
|
||||
);
|
||||
ensure!(
|
||||
fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"旧提交无效"
|
||||
);
|
||||
validate_change(&cfg.repository, fields[0], fields[1], publisher).await?;
|
||||
let latest = config(&config_path)?;
|
||||
let current = authorized(&latest, &identity)?;
|
||||
ensure!(
|
||||
current.prefixes == publisher.prefixes && current.ssh_key == publisher.ssh_key,
|
||||
"推送期间授权已变更,请重新连接"
|
||||
);
|
||||
eprintln!(
|
||||
"ChuBlog: identity={identity} validated commit={}",
|
||||
fields[1]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Fetch into API-owned storage: the SSH account cannot alter published refs or snapshots.
|
||||
pub async fn import(repo: &Path, source: &Path, current: &Snapshot) -> Result<Snapshot> {
|
||||
content::git(
|
||||
repo,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-recurse-submodules",
|
||||
"--",
|
||||
source.to_str().context("接收路径无效")?,
|
||||
"refs/heads/main",
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let commit = String::from_utf8(content::git(repo, &["rev-parse", "FETCH_HEAD"], None).await?)?;
|
||||
let commit = commit.trim();
|
||||
if commit == current.commit {
|
||||
return Ok(current.clone());
|
||||
}
|
||||
let snapshot = content::scan_at(repo, commit).await?;
|
||||
content::git(
|
||||
repo,
|
||||
&["update-ref", "refs/heads/main", commit, ¤t.commit],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn authorized_keys() -> Result<()> {
|
||||
let path =
|
||||
std::env::var("CHUBLOG_SSH_CONFIG").unwrap_or_else(|_| "/auth/publishers.json".into());
|
||||
let cfg = config(Path::new(&path))?;
|
||||
for (identity, grant) in &cfg.publishers {
|
||||
if authorized(&cfg, identity).is_ok()
|
||||
&& let Some(key) = &grant.ssh_key
|
||||
{
|
||||
let key = crate::accounts::public_key(key)?;
|
||||
println!("restrict,command=\"/usr/local/bin/chublog-api ssh-gate {identity}\" {key}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
use super::*;
|
||||
use accounts::{Grant, Role};
|
||||
use axum::http::Request;
|
||||
use std::collections::BTreeMap;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn markdown(key: &str, status: &str, body: &str) -> String {
|
||||
format!(
|
||||
"---\ntitle: Test\nslug: {key}\ndate: '2026-10-15'\nupdated: '2026-10-15'\nstatus: {status}\ntags: [Rust]\ndescription: Test article\n---\n\n{body}\n"
|
||||
)
|
||||
}
|
||||
fn grant(prefix: &str) -> Grant {
|
||||
Grant {
|
||||
enabled: true,
|
||||
role: Role::Publisher,
|
||||
prefixes: vec![prefix.into()],
|
||||
ssh_key: None,
|
||||
}
|
||||
}
|
||||
async fn commit(
|
||||
repo: &std::path::Path,
|
||||
parent: Option<&str>,
|
||||
files: &[(&str, &str, &str)],
|
||||
) -> String {
|
||||
content::git(repo, &["read-tree", "--empty"], None)
|
||||
.await
|
||||
.unwrap();
|
||||
for (path, mode, body) in files {
|
||||
let object = String::from_utf8(
|
||||
content::git(
|
||||
repo,
|
||||
&["hash-object", "-w", "--stdin"],
|
||||
Some(body.as_bytes()),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
content::git(
|
||||
repo,
|
||||
&[
|
||||
"update-index",
|
||||
"--add",
|
||||
"--cacheinfo",
|
||||
mode,
|
||||
object.trim(),
|
||||
path,
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let tree = String::from_utf8(content::git(repo, &["write-tree"], None).await.unwrap()).unwrap();
|
||||
let mut args = vec!["commit-tree", tree.trim(), "-m", "Test"];
|
||||
if let Some(parent) = parent {
|
||||
args.extend(["-p", parent]);
|
||||
}
|
||||
String::from_utf8(content::git(repo, &args, None).await.unwrap())
|
||||
.unwrap()
|
||||
.trim()
|
||||
.into()
|
||||
}
|
||||
#[test]
|
||||
fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
|
||||
for body in [
|
||||
"<script>alert(1)</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"[x](javascript:alert%281%29)",
|
||||
"[x](javascript:alert%281%29)",
|
||||
"",
|
||||
"",
|
||||
"a\u{202e}exe",
|
||||
"[x](//evil.test)",
|
||||
"[x](file:///etc/passwd)",
|
||||
] {
|
||||
assert!(
|
||||
content::parse(markdown("alice-test", "published", body), vec![]).is_err(),
|
||||
"accepted {body}"
|
||||
);
|
||||
}
|
||||
assert!(content::parse(markdown("../escape", "published", "Hello"), vec![]).is_err());
|
||||
assert!(
|
||||
content::parse(
|
||||
markdown(
|
||||
"alice-test",
|
||||
"published",
|
||||
&"x".repeat(content::MAX_MARKDOWN)
|
||||
),
|
||||
vec![]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
content::parse(
|
||||
markdown(
|
||||
"alice-test",
|
||||
"published",
|
||||
"```html\n<script>alert(1)</script>\n```\n[valid](https://example.com)"
|
||||
),
|
||||
vec![]
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
content::decode_asset(&content::Asset {
|
||||
name: "hidden.svg".into(),
|
||||
data: "PHN2Zz4=".into()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
content::decode_asset(&content::Asset {
|
||||
name: "fake.png".into(),
|
||||
data: "PHNjcmlwdD4=".into()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn git_checks_namespace_history_and_file_modes_before_publication() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = content::init(dir.path()).await.unwrap();
|
||||
let zero = "0000000000000000000000000000000000000000";
|
||||
let first = commit(
|
||||
&repo,
|
||||
None,
|
||||
&[(
|
||||
"posts/alice-first/index.md",
|
||||
"100644",
|
||||
&markdown("alice-first", "published", "Hello"),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
let snapshot = ssh::validate_change(&repo, zero, &first, &grant("alice-"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.posts.len(), 1);
|
||||
assert!(
|
||||
ssh::validate_change(&repo, zero, &first, &grant("bob-"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let crlf = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[(
|
||||
"posts/alice-first/index.md",
|
||||
"100644",
|
||||
&markdown("alice-first", "published", "Hello").replace('\n', "\r\n"),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &crlf, &grant("bob-"))
|
||||
.await
|
||||
.is_err(),
|
||||
"normalization must not bypass path authorization"
|
||||
);
|
||||
let second = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[(
|
||||
"posts/alice-first/index.md",
|
||||
"100644",
|
||||
&markdown("alice-first", "draft", "Private"),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
ssh::validate_change(&repo, &first, &second, &grant("alice-"))
|
||||
.await
|
||||
.unwrap();
|
||||
let third = commit(
|
||||
&repo,
|
||||
Some(&second),
|
||||
&[(
|
||||
"posts/alice-first/index.md",
|
||||
"100644",
|
||||
&markdown("alice-first", "published", "Skipped commit"),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &third, &grant("alice-"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let link = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[("posts/alice-first/index.md", "120000", "/etc/passwd")],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &link, &grant("alice-"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let executable = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[(
|
||||
"posts/alice-first/index.md",
|
||||
"100755",
|
||||
&markdown("alice-first", "published", "Hello"),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &executable, &grant("alice-"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let hidden = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[(".github/workflows/run.yml", "100644", "bad")],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &hidden, &grant("alice-"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, zero, &grant("alice-"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
content::persist(dir.path(), &content::scan_at(&repo, &second).await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let public: Snapshot = serde_json::from_slice(
|
||||
&tokio::fs::read(dir.path().join("published.json"))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(public.posts.is_empty(), "draft leaked into public snapshot");
|
||||
}
|
||||
#[test]
|
||||
fn ssh_keys_cannot_inject_options_or_commands() {
|
||||
let mut bytes = vec![0, 0, 0, 11];
|
||||
bytes.extend(b"ssh-ed25519");
|
||||
bytes.extend([0, 0, 0, 32]);
|
||||
bytes.extend([42; 32]);
|
||||
let key = format!(
|
||||
"ssh-ed25519 {} comment",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
);
|
||||
assert!(accounts::public_key(&key).is_ok());
|
||||
assert!(accounts::public_key(&format!("command=\"sh\" {key}")).is_err());
|
||||
assert!(accounts::public_key(&format!("{key}\nssh-rsa AAA")).is_err());
|
||||
assert!(accounts::public_key("ssh-ed25519 AAAA").is_err());
|
||||
}
|
||||
async fn app(dir: &std::path::Path) -> Arc<App> {
|
||||
let config = dir.join("publishers.json");
|
||||
let hash = accounts::hash_password("Administrator-test-123".into())
|
||||
.await
|
||||
.unwrap();
|
||||
accounts::atomic(
|
||||
&dir.join("users.json"),
|
||||
&serde_json::to_vec(&json!({"admin": {"hash": hash}})).unwrap(),
|
||||
0o600,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let grants = ssh::Config {
|
||||
repository: dir.join("ingress.git"),
|
||||
publishers: BTreeMap::from([(
|
||||
"admin".into(),
|
||||
Grant {
|
||||
enabled: true,
|
||||
role: Role::Admin,
|
||||
prefixes: vec![],
|
||||
ssh_key: None,
|
||||
},
|
||||
)]),
|
||||
};
|
||||
accounts::atomic(&config, &serde_json::to_vec(&grants).unwrap(), 0o644)
|
||||
.await
|
||||
.unwrap();
|
||||
let accounts = accounts::load(dir, &config, grants.repository)
|
||||
.await
|
||||
.unwrap();
|
||||
let published = content::parse(markdown("alice-public", "published", "Hello"), vec![]).unwrap();
|
||||
let draft = content::parse(markdown("alice-secret", "draft", "Secret"), vec![]).unwrap();
|
||||
Arc::new(App {
|
||||
data: dir.into(),
|
||||
ssh_config: config,
|
||||
origin: "http://localhost:4321".into(),
|
||||
secure: false,
|
||||
registration: true,
|
||||
accounts: Mutex::new(accounts),
|
||||
hash_slots: Arc::new(Semaphore::new(2)),
|
||||
snapshot: RwLock::new(Snapshot {
|
||||
commit: "test".into(),
|
||||
posts: BTreeMap::from([
|
||||
("alice-public".into(), published),
|
||||
("alice-secret".into(), draft),
|
||||
]),
|
||||
}),
|
||||
indexed: RwLock::new(None),
|
||||
views: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
async fn request(
|
||||
app: Arc<App>,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
cookie: Option<&str>,
|
||||
csrf: Option<&str>,
|
||||
) -> (StatusCode, HeaderMap, Value) {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://localhost:4321");
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header("cookie", cookie);
|
||||
}
|
||||
if let Some(csrf) = csrf {
|
||||
builder = builder.header("x-csrf-token", csrf);
|
||||
}
|
||||
let response = router(app)
|
||||
.oneshot(builder.body(Body::from(body.to_string())).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
(
|
||||
status,
|
||||
headers,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn registration_login_grants_revocation_and_private_content_boundaries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = app(dir.path()).await;
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
json!({"username":"alice", "password":"Alice-test-password-123", "role":"admin"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let (status, _, value) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
json!({"username":"alice", "password":"Alice-test-password-123"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(value["role"], "reader");
|
||||
let (_, headers, value) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
json!({"username":"alice", "password":"Alice-test-password-123"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let alice_cookie = headers[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let alice_csrf = value["csrf"].as_str().unwrap().to_owned();
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/admin/users",
|
||||
Value::Null,
|
||||
Some(&alice_cookie),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let (_, headers, value) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
json!({"username":"admin", "password":"Administrator-test-123"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let admin_cookie = headers[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let admin_csrf = value["csrf"].as_str().unwrap().to_owned();
|
||||
let input = json!({"enabled":true,"role":"publisher","prefixes":["alice-"]});
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
"/api/admin/users/alice",
|
||||
input.clone(),
|
||||
Some(&admin_cookie),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
"/api/admin/users/alice",
|
||||
input,
|
||||
Some(&admin_cookie),
|
||||
Some(&admin_csrf),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
app.accounts.lock().await.grants.publishers["alice"].role,
|
||||
Role::Publisher
|
||||
);
|
||||
let disk = tokio::fs::read(dir.path().join("users.json"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!String::from_utf8(disk)
|
||||
.unwrap()
|
||||
.contains("Alice-test-password-123")
|
||||
);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
json!({}),
|
||||
Some(&alice_cookie),
|
||||
Some(&alice_csrf),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"grant change must invalidate existing sessions"
|
||||
);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
"/api/admin/users/alice",
|
||||
json!({"enabled":false,"role":"reader","prefixes":[]}),
|
||||
Some(&admin_cookie),
|
||||
Some(&admin_csrf),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
json!({"username":"alice", "password":"Alice-test-password-123"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let (_, _, value) = request(app.clone(), "GET", "/api/posts", Value::Null, None, None).await;
|
||||
assert_eq!(value["posts"].as_array().unwrap().len(), 1);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/posts/alice-secret",
|
||||
Value::Null,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
let (_, _, value) = request(app.clone(), "GET", "/api/stats", Value::Null, None, None).await;
|
||||
assert_eq!(value["published_posts"], 1);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/admin/posts/publish",
|
||||
json!({}),
|
||||
Some(&admin_cookie),
|
||||
Some(&admin_csrf),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"online publishing must not exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_rotation_origin_and_request_limits() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = app(dir.path()).await;
|
||||
let response = router(app.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({"username":"admin","password":"Administrator-test-123"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"login must validate Origin"
|
||||
);
|
||||
let (_, headers, value) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
json!({"username":"admin","password":"Administrator-test-123"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let cookie = headers[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let csrf = value["csrf"].as_str().unwrap();
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
"/api/account/password",
|
||||
json!({"old_password":"Incorrect-password", "new_password":"New-password-12345"}),
|
||||
Some(&cookie),
|
||||
Some(csrf),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
"/api/account/password",
|
||||
json!({"old_password":"Administrator-test-123", "new_password":"New-password-12345"}),
|
||||
Some(&cookie),
|
||||
Some(csrf),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/account",
|
||||
Value::Null,
|
||||
Some(&cookie),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let loaded = accounts::load(dir.path(), &app.ssh_config, dir.path().join("ingress.git"))
|
||||
.await
|
||||
.unwrap();
|
||||
*app.accounts.lock().await = loaded;
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
json!({"username":"admin", "password":"Administrator-test-123"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let (status, _, _) = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
json!({"username":"admin", "password":"New-password-12345"}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"password change must survive account reload"
|
||||
);
|
||||
let (status, _, _) = request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
json!({"username":"large", "password":"x".repeat(9000)}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
Reference in new issue
Block a user