Initialize ChuBlog backend with SSH Git publishing

This commit is contained in:
TheBadRoger committed 2026-10-15 20:08:15 +08:00
commit a631d75cee
31 files changed
+4850

No files matched your search

+26
View File
@@ -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"] }
+657
View File
@@ -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": "请使用新口令重新登录"}),
))
}
+374
View File
@@ -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(())
}
+145
View File
@@ -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)
}
+330
View File
@@ -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, &current).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;
+274
View File
@@ -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, &current.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(())
}
+614
View File
@@ -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](jav&#x61;script:alert%281%29)",
"![x](https://evil.test/x.png)",
"![x](../secret.png)",
"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);
}