整理文档

This commit is contained in:
ChuNa7a committed 2026-10-16 18:09:11 +08:00
1 parent a18362fd60
commit a8c22b1b07
22 files changed
+1495 -519

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8.23"
ssh-key = { version = "0.6.7", default-features = false, features = ["std"] }
tokio = { version = "1", features = ["full"] }
pulldown-cmark = "0.13"
+2 -19
View File
@@ -34,6 +34,7 @@ pub enum Role {
pub struct Grant {
pub enabled: bool,
pub role: Role,
#[serde(default)]
pub prefixes: Vec<String>,
pub ssh_key: Option<String>,
}
@@ -507,6 +508,7 @@ pub async fn users(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult
pub struct GrantInput {
enabled: bool,
role: Role,
#[serde(default)]
prefixes: Vec<String>,
}
pub async fn grant(
@@ -517,9 +519,6 @@ pub async fn grant(
) -> 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
@@ -535,22 +534,6 @@ pub async fn grant(
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
+381 -85
View File
@@ -1,6 +1,5 @@
use anyhow::{Context, Result, ensure};
use base64::{Engine, engine::general_purpose::STANDARD};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
@@ -23,10 +22,15 @@ pub enum Status {
Published,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Metadata {
pub title: String,
#[serde(default)]
pub cover: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub slug: String,
pub date: String,
pub updated: String,
@@ -35,24 +39,51 @@ pub struct Metadata {
pub description: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Asset {
pub name: String,
pub data: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Post {
pub meta: Metadata,
pub markdown: String,
#[serde(default)]
pub entry: String,
#[serde(default)]
pub pages: BTreeMap<String, String>,
pub assets: Vec<Asset>,
#[serde(default)]
pub author: String,
#[serde(default)]
pub article_id: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub git_commit: String,
#[serde(default = "markdown_format")]
pub format: String,
}
fn markdown_format() -> String {
"markdown".into()
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Snapshot {
pub commit: String,
pub posts: BTreeMap<String, Post>,
#[serde(default)]
pub revisions: BTreeMap<String, String>,
#[serde(default)]
pub pending: BTreeMap<String, PendingIndex>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct PendingIndex {
pub commit: String,
pub reason: String,
}
pub fn slug(value: &str) -> Result<&str> {
@@ -75,51 +106,38 @@ pub fn slug(value: &str) -> Result<&str> {
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 stamp(post: &mut Post, previous: Option<&Post>, now: &str) {
post.meta.created_at = previous
.map(|p| p.meta.created_at.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| now.into());
post.meta.date = previous
.map(|p| p.meta.date.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
if post.meta.status == Status::Published {
now.into()
} else {
String::new()
}
});
let unchanged = previous.is_some_and(|p| {
p.meta.title == post.meta.title
&& p.meta.description == post.meta.description
&& p.meta.tags == post.meta.tags
&& p.meta.cover == post.meta.cover
&& p.meta.status == post.meta.status
&& p.path == post.path
&& p.pages == post.pages
&& p.assets == post.assets
&& p.markdown == post.markdown
&& p.entry == post.entry
});
post.meta.updated = if unchanged {
previous.unwrap().meta.updated.clone()
} else {
now.into()
};
}
pub fn decode_asset(asset: &Asset) -> Result<Vec<u8>> {
@@ -131,7 +149,7 @@ pub fn decode_asset(asset: &Asset) -> Result<Vec<u8>> {
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
&& name != "index.md",
&& !matches!(name.as_str(), "index.md" | "index.markdown" | "index.txt"),
"图片文件名无效"
);
ensure!(asset.data.len() <= 3 * 1024 * 1024, "图片超过大小限制");
@@ -261,19 +279,139 @@ pub async fn init(data: &Path) -> Result<PathBuf> {
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
#[derive(Debug)]
pub enum Scan {
Ready(Snapshot),
Pending(String),
}
impl Scan {
pub fn ready(self) -> Result<Snapshot> {
match self {
Self::Ready(snapshot) => Ok(snapshot),
Self::Pending(reason) => anyhow::bail!("索引待修复:{reason}"),
}
}
}
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();
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
#[serde(default)]
articles: Vec<ManifestEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEntry {
pub id: String,
pub folder: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub cover: Option<String>,
#[serde(default, rename = "public")]
pub is_public: bool,
#[serde(default = "default_entry")]
pub entry: String,
}
fn default_entry() -> String {
"main.md".into()
}
pub fn manifest(text: &str) -> Result<Vec<ManifestEntry>> {
ensure!(text.len() <= 65536, "index.toml 超过 64 KiB");
validate_text(text)?;
let index: Manifest = toml::from_str(text).context("index.toml 格式或字段无效")?;
ensure!(index.articles.len() <= 1000, "最多 1000 篇文章");
let mut ids = std::collections::BTreeSet::new();
let mut folders = std::collections::BTreeSet::new();
for article in &index.articles {
slug(&article.id)?;
ensure!(
article.id.len() <= 40 && ids.insert(&article.id),
"文章 ID 过长或重复"
);
slug(
article
.folder
.strip_prefix("posts/")
.context("文章目录必须位于 posts 下")?,
)?;
ensure!(folders.insert(&article.folder), "文章目录不能重复映射");
filename(&article.entry)?;
ensure!(
matches!(
article.entry.rsplit('.').next(),
Some("md" | "markdown" | "txt")
),
"入口必须为 md、markdown 或 txt"
);
ensure!(
!article.title.trim().is_empty()
&& article.title.len() <= 240
&& article.description.len() <= 1000,
"标题或简介长度无效"
);
ensure!(
article.tags.len() <= 12
&& article
.tags
.iter()
.all(|t| !t.trim().is_empty() && t.len() <= 80),
"标签无效"
);
if let Some(cover) = &article.cover {
filename(cover)?;
}
}
Ok(index.articles)
}
fn filename(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.len() <= 100
&& !name.starts_with('.')
&& !name.contains("..")
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)),
"文件名无效"
);
Ok(())
}
pub fn validate_text(text: &str) -> Result<()> {
ensure!(
!text.contains("-----BEGIN ") && !text.contains("-----END "),
"禁止提交 PEM 证书或密钥"
);
ensure!(
!text.chars().any(|c| c == '\0'
|| (c.is_control() && !"\n\r\t".contains(c))
|| matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')),
"禁止控制符及双向隐藏字符"
);
Ok(())
}
#[derive(Default)]
struct Folder {
texts: BTreeMap<String, String>,
assets: Vec<Asset>,
bytes: usize,
}
pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
let entries = git(repo, &["ls-tree", "-rz", commit], None).await?;
let mut grouped: BTreeMap<String, Folder> = BTreeMap::new();
let mut index = None;
let mut total = 0;
let mut count = 0;
for entry in entries.split(|b| *b == 0).filter(|e| !e.is_empty()) {
count += 1;
ensure!(count <= 14001, "仓库文件数超过限制");
let entry = std::str::from_utf8(entry)?;
let (info, path) = entry.split_once('\t').context("Git 树格式错误")?;
let info: Vec<_> = info.split_whitespace().collect();
@@ -282,46 +420,201 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Snapshot> {
"禁止符号链接及特殊文件"
);
let parts: Vec<_> = path.split('/').collect();
let is_index = path == "index.toml";
if !is_index {
ensure!(
parts.len() == 3 && parts[0] == "posts",
"仅允许 index.toml 和 posts/文件夹/文件"
);
slug(parts[1])?;
filename(parts[2])?;
}
let extension = path.rsplit('.').next().unwrap_or("");
let text_file = matches!(extension, "txt" | "md" | "markdown");
ensure!(
parts.len() == 3 && parts[0] == "posts",
"仓库包含非文章路径"
is_index || text_file || matches!(extension, "png" | "jpg" | "jpeg" | "webp"),
"仅允许 txt、md、markdown、PNG/JPEG/WebP;禁止证书、配置及隐藏文件"
);
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" {
let limit = if is_index {
65536
} else if text_file {
MAX_MARKDOWN
} else {
2 * 1024 * 1024
};
ensure!(size <= limit, "文件超过大小限制");
total += size;
ensure!(total <= MAX_SNAPSHOT, "作者仓库超过 64 MiB 配额");
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)?);
if is_index {
index = Some(String::from_utf8(bytes).context("index.toml 必须为 UTF-8"));
continue;
}
let folder = grouped.entry(format!("posts/{}", parts[1])).or_default();
folder.bytes += bytes.len();
ensure!(folder.bytes <= 6 * 1024 * 1024, "文章目录不得超过 6 MiB");
if text_file {
let text = String::from_utf8(bytes).context("文本必须为 UTF-8")?;
validate_text(&text)?;
folder
.texts
.insert(parts[2].into(), text.replace("\r\n", "\n"));
} else {
group.1.push(Asset {
let asset = Asset {
name: parts[2].into(),
data: STANDARD.encode(bytes),
});
};
decode_asset(&asset)?;
folder.assets.push(asset);
ensure!(folder.assets.len() <= 12, "每个目录最多 12 张图片");
}
ensure!(grouped.len() <= 1000, "最多 1000 篇文章");
ensure!(grouped.len() <= 1000, "最多 1000 个文章目录");
}
for folder in grouped.values() {
for text in folder.texts.values() {
validate_markdown(text, &folder.assets)?;
}
}
// Index/schema failures stage the commit, while security failures above reject the push.
let index = match index {
Some(Ok(text)) => match manifest(&text) {
Ok(index) => index,
Err(error) => return Ok(Scan::Pending(error.to_string())),
},
Some(Err(error)) => return Ok(Scan::Pending(error.to_string())),
None => return Ok(Scan::Pending("缺少 index.toml".into())),
};
let result = build_posts(index, &mut grouped, commit);
match result {
Ok(posts) => Ok(Scan::Ready(Snapshot {
commit: commit.into(),
posts,
revisions: BTreeMap::new(),
pending: BTreeMap::new(),
})),
Err(error) => Ok(Scan::Pending(error.to_string())),
}
}
fn local_target(url: &str) -> &str {
url.split('#')
.next()
.unwrap_or("")
.strip_prefix("./")
.unwrap_or_else(|| url.split('#').next().unwrap_or(""))
}
fn is_external(url: &str) -> bool {
url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("mailto:")
|| url.starts_with('/')
|| url.starts_with('#')
}
fn build_posts(
index: Vec<ManifestEntry>,
grouped: &mut BTreeMap<String, Folder>,
commit: &str,
) -> Result<BTreeMap<String, Post>> {
use pulldown_cmark::{Event, Parser, Tag};
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);
for article in index {
let Some(folder) = grouped.remove(&article.folder) else {
continue;
};
// A valid index with no entry document denotes an absent article.
let Some(body) = folder.texts.get(&article.entry).cloned() else {
continue;
};
let mut pages = BTreeMap::new();
let mut queue = vec![article.entry.clone()];
let mut images = std::collections::BTreeSet::new();
if let Some(cover) = &article.cover {
images.insert(cover.clone());
}
while let Some(name) = queue.pop() {
if pages.contains_key(&name) {
continue;
}
let text = folder
.texts
.get(&name)
.context("文章内链接指向不存在的页面")?;
for event in Parser::new_ext(text, pulldown_cmark::Options::all()) {
match event {
Event::Start(Tag::Link { dest_url, .. }) if !is_external(&dest_url) => {
let target = local_target(&dest_url);
ensure!(
folder.texts.contains_key(target),
"文章内链接指向不存在的页面"
);
queue.push(target.into());
}
Event::Start(Tag::Image { dest_url, .. }) => {
images.insert(local_target(&dest_url).into());
}
_ => {}
}
}
pages.insert(name, text.clone());
}
ensure!(
images
.iter()
.all(|name| folder.assets.iter().any(|a| &a.name == name)),
"封面或图片不存在"
);
let assets = folder
.assets
.into_iter()
.filter(|a| images.contains(&a.name))
.collect();
let id = article.id;
posts.insert(
id.clone(),
Post {
meta: Metadata {
title: article.title,
slug: id.clone(),
description: article.description,
tags: article.tags,
cover: article.cover,
status: if article.is_public {
Status::Published
} else {
Status::Draft
},
date: String::new(),
updated: String::new(),
created_at: String::new(),
},
markdown: body,
entry: article.entry.clone(),
pages,
assets,
author: String::new(),
article_id: id,
path: format!("{}/{}", article.folder, article.entry),
git_commit: commit.into(),
format: if article.entry.ends_with(".txt") {
"text"
} else {
"markdown"
}
.into(),
},
);
}
Ok(Snapshot { commit, posts })
Ok(posts)
}
pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> {
let public = Snapshot {
commit: snapshot.commit.clone(),
revisions: BTreeMap::new(),
pending: BTreeMap::new(),
posts: snapshot
.posts
.iter()
@@ -340,12 +633,7 @@ pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> {
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}')),
"禁止控制符及双向隐藏字符"
);
validate_text(body)?;
for event in Parser::new_ext(body, pulldown_cmark::Options::all()) {
match event {
Event::Html(_) | Event::InlineHtml(_) => anyhow::bail!("Markdown 不允许原始 HTML"),
@@ -355,7 +643,15 @@ pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> {
|| dest_url.starts_with("http://")
|| dest_url.starts_with("mailto:")
|| dest_url.starts_with('#')
|| (dest_url.starts_with('/') && !dest_url.starts_with("//")),
|| (dest_url.starts_with('/') && !dest_url.starts_with("//"))
|| (!is_external(&dest_url) && {
let target = local_target(&dest_url);
filename(target).is_ok()
&& matches!(
target.rsplit('.').next(),
Some("md" | "markdown" | "txt")
)
}),
"链接协议不允许"
);
ensure!(
@@ -364,7 +660,7 @@ pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> {
);
}
Event::Start(Tag::Image { dest_url, .. }) => ensure!(
assets.iter().any(|a| a.name == dest_url.as_ref()),
assets.iter().any(|a| a.name == local_target(&dest_url)),
"图片必须引用同文章已验证的图片文件"
),
_ => {}
+43 -2
View File
@@ -45,6 +45,30 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)")
.await?;
}
if db
.query("SELECT id FROM blog_articles LIMIT 1")
.await
.is_err()
{
db.query("CREATE TABLE blog_articles (id TEXT PRIMARY KEY, author TEXT, article_id TEXT, title TEXT, description TEXT, status TEXT, published_at TEXT, updated_at TEXT, path TEXT, format TEXT, git_commit TEXT)").await?;
}
db.query("BEGIN").await?;
db.query("DELETE FROM blog_articles").await?;
for post in snapshot.posts.values() {
let meta = &post.meta;
let status = match meta.status {
Status::Draft => "draft",
Status::Published => "published",
};
let q = Query::new("INSERT INTO blog_articles (id, author, article_id, title, description, status, published_at, updated_at, path, format, git_commit) VALUES (")
.bind(meta.slug.as_str()).push(", ").bind(post.author.as_str()).push(", ")
.bind(post.article_id.as_str()).push(", ").bind(meta.title.as_str()).push(", ")
.bind(meta.description.as_str()).push(", ").bind(status).push(", ")
.bind(meta.date.as_str()).push(", ").bind(meta.updated.as_str()).push(", ")
.bind(post.path.as_str()).push(", ").bind(post.format.as_str()).push(", ")
.bind(post.git_commit.as_str()).push(")");
db.query(q.as_str()).await?;
}
db.query("DELETE FROM blog_tags").await?;
db.query("DELETE FROM blog_posts").await?;
for post in snapshot
@@ -56,7 +80,7 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
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())
.bind(meta.updated.as_str()).push(", ").bind(post.path.as_str())
.push(", ").bind(snapshot.commit.as_str()).push(")");
db.query(q.as_str()).await?;
for tag in &meta.tags {
@@ -70,6 +94,7 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
db.query(q.as_str()).await?;
}
}
db.query("COMMIT").await?;
db.close().await;
Ok(())
}
@@ -131,7 +156,23 @@ pub async fn consistent(snapshot: &Snapshot) -> Result<bool> {
.filter(|p| p.meta.status == Status::Published)
.map(|p| p.meta.tags.len())
.sum::<usize>() as u64;
let matches = !result.truncated
let articles = db
.query("SELECT id, git_commit FROM blog_articles LIMIT 1000")
.await?;
let expected_articles: std::collections::BTreeSet<_> = snapshot
.posts
.values()
.map(|p| (p.meta.slug.as_str(), p.git_commit.as_str()))
.collect();
let actual_articles: std::collections::BTreeSet<_> = articles
.rows
.iter()
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
.collect();
let matches = !articles.truncated
&& articles.rows.len() == expected_articles.len()
&& actual_articles == expected_articles
&& !result.truncated
&& result.rows.len() == expected.len()
&& actual == expected
&& count
+73 -19
View File
@@ -85,7 +85,27 @@ async fn public_post(
// 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<_>>()}),
json!({"meta": post.meta, "markdown": post.markdown, "format": post.format, "author": post.author, "article_id": post.article_id, "entry": post.entry, "pages": post.pages.keys().map(|name| json!({"name":name,"url":format!("/api/posts/{key}/pages/{name}")})).collect::<Vec<_>>(), "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::<Vec<_>>()}),
))
}
async fn page(
State(app): State<Arc<App>>,
Path((key, name)): Path<(String, 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()))?;
let text = post
.pages
.get(&name)
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "页面不存在".into()))?;
Ok(Json(
json!({"meta":post.meta,"name":name,"markdown":text,"format":if name.ends_with(".txt") { "text" } else { "markdown" },
"pages":post.pages.keys().map(|page| json!({"name":page,"url":format!("/api/posts/{key}/pages/{page}")})).collect::<Vec<_>>(),
"assets":post.assets.iter().map(|a| json!({"name":a.name,"url":format!("/api/assets/{key}/{}",a.name)})).collect::<Vec<_>>() }),
))
}
async fn asset(
@@ -132,7 +152,7 @@ async fn admin_status(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiRes
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}),
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, "pending": snapshot.pending}),
))
}
async fn sync_index(app: &App, snapshot: &Snapshot) {
@@ -153,6 +173,7 @@ fn router(app: Arc<App>) -> Router {
)
.route("/api/posts", get(posts))
.route("/api/posts/{slug}", get(public_post))
.route("/api/posts/{slug}/pages/{name}", get(page))
.route("/api/assets/{slug}/{name}", get(asset))
.route("/api/stats", get(stats))
.route("/api/auth/register", post(accounts::register))
@@ -186,19 +207,32 @@ fn router(app: Arc<App>) -> Router {
))
.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 {
async fn refresh(app: &App, source: &std::path::Path) -> Result<()> {
let current = app.snapshot.read().await.clone();
let identities: Vec<_> = app
.accounts
.lock()
.await
.grants
.publishers
.keys()
.cloned()
.collect();
let snapshot = ssh::import_authors(&app.data, source, &current, &identities).await?;
if snapshot.commit != current.commit || snapshot.pending != current.pending {
accounts::atomic(
&app.data.join("collection.json"),
&serde_json::to_vec(&snapshot)?,
0o600,
)
.await?;
content::persist(&app.data, &snapshot).await?;
info!(action = "published", commit = %snapshot.commit);
info!(action = "published", revision = %snapshot.commit);
let changed = snapshot.commit != current.commit;
*app.snapshot.write().await = snapshot;
if changed {
*app.indexed.write().await = None;
}
}
Ok(())
}
@@ -238,7 +272,21 @@ async fn main() -> Result<()> {
.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?;
let snapshot = match tokio::fs::read(data.join("collection.json")).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).context("文章集合快照损坏,请从私有作者仓库恢复")?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
ensure!(
content::git(&repo, &["ls-tree", "-r", "--name-only", "HEAD"], None)
.await?
.is_empty(),
"检测到旧共享文章仓库,请先按 README 迁移到作者独立仓库"
);
Snapshot::default()
}
Err(error) => return Err(error.into()),
};
content::persist(&data, &snapshot).await?;
if command.as_deref() == Some("rebuild-index") {
index::rebuild(&snapshot).await?;
@@ -263,9 +311,9 @@ async fn main() -> Result<()> {
"PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin"
);
let source = PathBuf::from(
std::env::var("INGRESS_REPO").unwrap_or_else(|_| "/srv/ingress/content.git".into()),
std::env::var("INGRESS_DIR").unwrap_or_else(|_| "/srv/ingress/authors".into()),
);
ensure!(source.is_absolute(), "INGRESS_REPO 必须是绝对路径");
ensure!(source.is_absolute(), "INGRESS_DIR 必须是绝对路径");
let ssh_config = PathBuf::from(
std::env::var("CHUBLOG_SSH_CONFIG")
.unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()),
@@ -273,7 +321,7 @@ async fn main() -> Result<()> {
let accounts = accounts::load(&data, &ssh_config, source.clone()).await?;
ensure!(
accounts.grants.repository == source,
"授权配置仓库与 INGRESS_REPO 不一致"
"授权配置仍指向旧共享仓库或与 INGRESS_DIR 不一致,请先按 README 迁移授权配置"
);
let app = Arc::new(App {
data,
@@ -290,11 +338,14 @@ async fn main() -> Result<()> {
let worker_app = app.clone();
let worker = tokio::spawn(async move {
let mut ticks = 0;
let mut retry_at = tokio::time::Instant::now();
loop {
if let Err(error) = refresh(&worker_app, &repo, &source).await {
if let Err(error) = refresh(&worker_app, &source).await {
warn!(action = "import_pending", error = %error);
}
if ticks % 30 == 0 {
if (ticks % 30 == 0 || worker_app.indexed.read().await.is_none())
&& tokio::time::Instant::now() >= retry_at
{
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;
@@ -306,6 +357,9 @@ async fn main() -> Result<()> {
*worker_app.indexed.write().await = None;
sync_index(&worker_app, &snapshot).await;
}
if worker_app.indexed.read().await.is_none() {
retry_at = tokio::time::Instant::now() + Duration::from_secs(30);
}
}
ticks += 1;
tokio::time::sleep(Duration::from_secs(2)).await;
+174 -74
View File
@@ -1,5 +1,5 @@
use crate::accounts::{Grant, Role};
use crate::content::{self, Snapshot};
use crate::content::{self, Scan, Snapshot};
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use std::{
@@ -44,16 +44,44 @@ fn authorized<'a>(cfg: &'a Config, identity: &str) -> Result<&'a Grant> {
.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)
}
pub fn author_repository(root: &Path, identity: &str) -> Result<PathBuf> {
ensure!(
(3..=32).contains(&identity.len())
&& identity.starts_with(|c: char| c.is_ascii_lowercase())
&& identity
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"作者仓库身份无效"
);
Ok(root.join(format!("{identity}.git")))
}
pub async fn init_author(root: &Path, identity: &str) -> Result<PathBuf> {
let repo = author_repository(root, identity)?;
if !repo.exists() {
tokio::fs::create_dir(&repo).await?;
content::git(
&repo,
&[
"init",
"--bare",
"--initial-branch=main",
"--shared=group",
".",
],
None,
)
.await?;
}
ensure!(
std::fs::symlink_metadata(&repo)?.is_dir(),
"作者仓库必须是普通目录"
);
Ok(repo)
}
fn directory_size(path: &Path) -> Result<u64> {
let mut total = 0u64;
for entry in std::fs::read_dir(path)? {
@@ -88,20 +116,22 @@ pub async fn gate(identity: &str) -> Result<()> {
"仅允许对 content.git 执行 Git 推送或读取"
);
let receiving = original.starts_with("git-receive-pack");
directory_size(&cfg.repository)?;
let repo = author_repository(&cfg.repository, identity)?;
let lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(cfg.repository.join("ssh.lock"))?;
.open(cfg.repository.join(format!(".{identity}.lock")))?;
fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?;
init_author(&cfg.repository, identity).await?;
directory_size(&cfg.repository)?;
let executable = if receiving {
"git-receive-pack"
} else {
"git-upload-pack"
};
let mut child = Command::new(executable)
.arg(&cfg.repository)
.arg(&repo)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", "/nonexistent")
@@ -138,12 +168,7 @@ pub async fn gate(identity: &str) -> Result<()> {
Ok(())
}
pub async fn validate_change(
repo: &Path,
previous: &str,
next: &str,
publisher: &Grant,
) -> Result<Snapshot> {
pub async fn validate_change(repo: &Path, previous: &str, next: &str) -> Result<Scan> {
ensure!(
next.len() == 40
&& next.bytes().all(|b| b.is_ascii_hexdigit())
@@ -158,36 +183,6 @@ pub async fn validate_change(
"每次只允许推送一个直接继承主分支的提交,首次必须为根提交;不允许合并或强推"
);
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)
}
@@ -214,7 +209,15 @@ pub async fn pre_receive() -> Result<()> {
fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()),
"旧提交无效"
);
validate_change(&cfg.repository, fields[0], fields[1], publisher).await?;
let repo = author_repository(&cfg.repository, &identity)?;
let actual = std::env::current_dir()?;
ensure!(
std::fs::canonicalize(&repo)? == std::fs::canonicalize(actual)?,
"推送仓库与作者身份不符"
);
if let Scan::Pending(reason) = validate_change(&repo, fields[0], fields[1]).await? {
eprintln!("ChuBlog: index.toml 待修复,提交已暂存,现有文章不变:{reason}");
}
let latest = config(&config_path)?;
let current = authorized(&latest, &identity)?;
ensure!(
@@ -228,34 +231,131 @@ pub async fn pre_receive() -> Result<()> {
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());
// Each fetch receives only one author's objects into an API-owned private cache.
pub async fn import_authors(
data: &Path,
source: &Path,
current: &Snapshot,
identities: &[String],
) -> Result<Snapshot> {
let cache = data.join("authors");
tokio::fs::create_dir_all(&cache).await?;
let mut combined = current.clone();
for identity in identities {
let incoming = author_repository(source, identity)?;
if !incoming.exists() {
continue;
}
ensure!(
std::fs::symlink_metadata(&incoming)?.is_dir(),
"接收仓库不是普通目录"
);
let result = async {
let author_data = cache.join(identity);
let repo = content::init(&author_data).await?;
let safe = format!("safe.directory={}", incoming.display());
// The initial bare repository may have no main branch until its first push.
let refs = content::git(
&repo,
&[
"-c",
&safe,
"ls-remote",
incoming.to_str().context("接收路径无效")?,
"refs/heads/main",
],
None,
)
.await?;
if refs.is_empty() {
return Ok::<_, anyhow::Error>(None);
}
content::git(
&repo,
&[
"-c",
&safe,
"fetch",
"--no-tags",
"--no-recurse-submodules",
"--",
incoming.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 current
.revisions
.get(identity)
.is_some_and(|previous| previous == commit)
|| current
.pending
.get(identity)
.is_some_and(|pending| pending.commit == commit)
{
return Ok(None);
}
let snapshot = content::scan_at(&repo, commit).await?;
content::git(&repo, &["update-ref", "refs/heads/main", commit], None).await?;
Ok(Some((commit.to_owned(), snapshot)))
}
.await;
match result {
Ok(Some((commit, Scan::Pending(reason)))) => {
combined
.pending
.insert(identity.clone(), content::PendingIndex { commit, reason });
}
Ok(Some((_, Scan::Ready(snapshot)))) => {
let mut candidate = combined.clone();
candidate.posts.retain(|_, post| post.author != *identity);
for (id, mut post) in snapshot.posts {
post.author = identity.clone();
// Neither username nor local ID allows '~', so this is collision-free.
let key = format!("{identity}~{id}");
post.meta.slug = key.clone();
let now =
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
content::stamp(&mut post, combined.posts.get(&key), &now);
candidate.posts.insert(key, post);
}
let bytes: usize = candidate
.posts
.values()
.map(|p| {
p.markdown.len()
+ p.assets.iter().map(|a| a.data.len() * 3 / 4).sum::<usize>()
})
.sum();
if candidate.posts.len() > 1000 || bytes > content::MAX_SNAPSHOT {
tracing::warn!(action = "author_import_pending", author = %identity, "站点文章数量或容量超过上限");
continue;
}
candidate
.revisions
.insert(identity.clone(), snapshot.commit);
candidate.pending.remove(identity);
combined = candidate;
}
Ok(None) => {}
Err(error) => {
tracing::warn!(action = "author_import_pending", author = %identity, error = %error)
}
}
}
let snapshot = content::scan_at(repo, commit).await?;
content::git(
repo,
&["update-ref", "refs/heads/main", commit, &current.commit],
None,
let repo = content::init(data).await?;
let digest = content::git(
&repo,
&["hash-object", "--stdin"],
Some(&serde_json::to_vec(&combined.revisions)?),
)
.await?;
Ok(snapshot)
combined.commit = String::from_utf8(digest)?.trim().into();
Ok(combined)
}
pub fn authorized_keys() -> Result<()> {
+244 -102
View File
@@ -9,31 +9,30 @@ fn markdown(key: &str, status: &str, body: &str) -> String {
"---\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 {
let files: Vec<_> = files
.iter()
.map(|(path, mode, body)| (*path, *mode, body.as_bytes()))
.collect();
commit_bytes(repo, parent, &files).await
}
async fn commit_bytes(
repo: &std::path::Path,
parent: Option<&str>,
files: &[(&str, &str, &[u8])],
) -> 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(),
content::git(repo, &["hash-object", "-w", "--stdin"], Some(body))
.await
.unwrap(),
)
.unwrap();
content::git(
@@ -117,117 +116,143 @@ fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
.is_err()
);
}
#[test]
fn manifest_requires_unique_ids_safe_folders_and_bounded_text() {
let index =
content::manifest("first posts/writing article.md\nsecond posts/notes body.markdown\n")
.unwrap();
assert_eq!(index["first"].folder, "posts/writing");
assert_eq!(index["first"].file, "article.md");
for text in [
"first posts/a a.md\nfirst posts/b b.md",
"first posts/a a.md\nsecond posts/a b.md",
"first ../escape",
"first posts/../escape",
"first posts/.git",
"first posts/a ../a.md",
"first posts/a .gitignore",
"first posts/a a.pem",
"../escape posts/a",
"first posts/a\0",
"-----BEGIN CERTIFICATE-----",
"first posts/a\u{202e}",
] {
assert!(content::manifest(text).is_err(), "accepted {text}");
}
assert!(content::manifest(&" ".repeat(65537)).is_err());
assert!(ssh::author_repository(std::path::Path::new("/private"), "../bob").is_err());
}
#[tokio::test]
async fn git_checks_namespace_history_and_file_modes_before_publication() {
async fn git_manifest_controls_publication_and_validates_unlisted_files_and_history() {
let dir = tempfile::tempdir().unwrap();
let repo = content::init(dir.path()).await.unwrap();
let zero = "0000000000000000000000000000000000000000";
let first = commit(
let body = markdown("first", "published", "Hello");
let files = [
("index.txt", "100644", "first posts/writing index.md\n"),
("posts/writing/index.md", "100644", body.as_str()),
];
let first = commit(&repo, None, &files).await;
let snapshot = ssh::validate_change(&repo, zero, &first).await.unwrap();
assert_eq!(snapshot.posts["first"].path, "posts/writing/index.md");
let removed = commit(
&repo,
None,
&[(
"posts/alice-first/index.md",
Some(&first),
&[("index.txt", "100644", ""), files[1]],
)
.await;
assert!(
ssh::validate_change(&repo, &first, &removed)
.await
.unwrap()
.posts
.is_empty()
);
// Unlisted content is inert but must never bypass the allowlist or validation.
for (path, mode, value) in [
(".gitignore", "100644", "secret"),
("posts/writing/cert.pem", "100644", "cert"),
(
"posts/writing/secret.txt",
"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",
"-----BEGIN PRIVATE KEY-----",
),
(
"posts/writing/hidden.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(
"<script>alert(1)</script>",
),
("posts/writing/fake.png", "100644", "not an image"),
("posts/other/index.md", "120000", "/etc/passwd"),
("posts/other/index.md", "100755", "executable"),
("posts/writing/payload.bin", "100644", "binary"),
] {
let candidate = commit(
&repo,
Some(&first),
&[files[0], files[1], (path, mode, value)],
)
.await;
assert!(
ssh::validate_change(&repo, &first, &candidate)
.await
.is_err(),
"accepted {path}"
);
}
let missing = commit(
&repo,
Some(&first),
&[(
"posts/alice-first/index.md",
"100644",
&markdown("alice-first", "draft", "Private"),
)],
&[("index.txt", "100644", "first posts/missing index.md")],
)
.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(
assert!(content::scan_at(&repo, &missing).await.is_err());
let duplicate_body = commit(
&repo,
Some(&first),
&[("posts/alice-first/index.md", "120000", "/etc/passwd")],
&[
files[0],
files[1],
("posts/writing/index.txt", "100644", body.as_str()),
],
)
.await;
assert!(
ssh::validate_change(&repo, &first, &link, &grant("alice-"))
assert_eq!(
content::scan_at(&repo, &duplicate_body)
.await
.is_err()
.unwrap()
.posts["first"]
.path,
"posts/writing/index.md"
);
let executable = commit(
let skipped = commit(&repo, Some(&removed), &files).await;
assert!(ssh::validate_change(&repo, &first, &skipped).await.is_err());
assert!(ssh::validate_change(&repo, &first, zero).await.is_err());
let plain = commit(
&repo,
Some(&first),
&[(
"posts/alice-first/index.md",
"100755",
&markdown("alice-first", "published", "Hello"),
)],
&[
("index.txt", "100644", "first posts/writing index.txt"),
("posts/writing/index.txt", "100644", body.as_str()),
],
)
.await;
assert!(
ssh::validate_change(&repo, &first, &executable, &grant("alice-"))
.await
.is_err()
assert_eq!(
content::scan_at(&repo, &plain).await.unwrap().posts["first"].format,
"text"
);
let hidden = commit(
let draft_body = markdown("first", "draft", "Secret");
let draft = commit(
&repo,
Some(&first),
&[(".github/workflows/run.yml", "100644", "bad")],
&[
("index.txt", "100644", "first posts/writing index.markdown"),
("posts/writing/index.markdown", "100644", &draft_body),
],
)
.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())
content::persist(dir.path(), &content::scan_at(&repo, &draft).await.unwrap())
.await
.unwrap();
let public: Snapshot = serde_json::from_slice(
@@ -236,7 +261,123 @@ async fn git_checks_namespace_history_and_file_modes_before_publication() {
.unwrap(),
)
.unwrap();
assert!(public.posts.is_empty(), "draft leaked into public snapshot");
assert!(public.posts.is_empty());
}
#[tokio::test]
async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images() {
let dir = tempfile::tempdir().unwrap();
let repo = content::init(dir.path()).await.unwrap();
let body = markdown("first", "published", "![cover](cover.png)").replace("slug: first\n", "");
let mut image = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(1, 1)
.write_to(&mut image, image::ImageFormat::Png)
.unwrap();
let commit = commit_bytes(
&repo,
None,
&[
(
"index.txt",
"100644",
b"first posts/writing article.markdown\n",
),
("posts/writing/article.markdown", "100644", body.as_bytes()),
("posts/writing/notes.txt", "100644", b"Unselected notes"),
("posts/unlisted/draft.txt", "100644", b"Not registered"),
("posts/writing/cover.png", "100644", image.get_ref()),
("posts/writing/private.png", "100644", image.get_ref()),
],
)
.await;
let snapshot = content::scan_at(&repo, &commit).await.unwrap();
assert_eq!(snapshot.posts.len(), 1);
let post = &snapshot.posts["first"];
assert_eq!(post.path, "posts/writing/article.markdown");
assert_eq!(post.meta.slug, "first");
assert_eq!(post.assets.len(), 1);
assert_eq!(post.assets[0].name, "cover.png");
assert!(
!serde_json::to_string(&snapshot)
.unwrap()
.contains("Unselected notes")
);
}
#[tokio::test]
async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_recovery() {
let dir = tempfile::tempdir().unwrap();
let ingress = dir.path().join("ingress");
std::fs::create_dir(&ingress).unwrap();
let alice = ssh::init_author(&ingress, "alice").await.unwrap();
let bob = ssh::init_author(&ingress, "bob").await.unwrap();
let a_body = markdown("first", "draft", "Alice private body");
let b_body = markdown("first", "published", "Bob public body");
let a = commit(
&alice,
None,
&[
("index.txt", "100644", "first posts/a index.md"),
("posts/a/index.md", "100644", &a_body),
],
)
.await;
let b = commit(
&bob,
None,
&[
("index.txt", "100644", "first posts/b index.md"),
("posts/b/index.md", "100644", &b_body),
],
)
.await;
content::git(&alice, &["update-ref", "refs/heads/main", &a], None)
.await
.unwrap();
content::git(&bob, &["update-ref", "refs/heads/main", &b], None)
.await
.unwrap();
assert!(
content::git(&alice, &["cat-file", "-t", &b], None)
.await
.is_err(),
"Bob objects leaked into Alice repository"
);
let data = dir.path().join("api");
let names = vec!["alice".into(), "bob".into()];
let first = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names)
.await
.unwrap();
assert_eq!(first.posts.len(), 2);
assert_eq!(first.posts["alice~first"].author, "alice");
assert_eq!(first.posts["bob~first"].article_id, "first");
let same = ssh::import_authors(&data, &ingress, &first, &names)
.await
.unwrap();
assert_eq!(same.commit, first.commit);
let removed = commit(
&bob,
Some(&b),
&[
("index.txt", "100644", ""),
("posts/b/index.md", "100644", &b_body),
],
)
.await;
content::git(&bob, &["update-ref", "refs/heads/main", &removed], None)
.await
.unwrap();
let next = ssh::import_authors(&data, &ingress, &first, &names)
.await
.unwrap();
assert!(!next.posts.contains_key("bob~first"));
assert!(next.posts.contains_key("alice~first"));
// Recover all authors from private Git caches after losing the derived collection.
let rebuilt = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names)
.await
.unwrap();
assert_eq!(rebuilt.commit, next.commit);
assert_eq!(rebuilt.posts.len(), 1);
}
#[test]
fn ssh_keys_cannot_inject_options_or_commands() {
@@ -347,6 +488,7 @@ async fn app(dir: &std::path::Path) -> Arc<App> {
hash_slots: Arc::new(Semaphore::new(2)),
snapshot: RwLock::new(Snapshot {
commit: "test".into(),
revisions: BTreeMap::new(),
posts: BTreeMap::from([
("alice-public".into(), published),
("alice-secret".into(), draft),
@@ -459,7 +601,7 @@ async fn registration_login_grants_revocation_and_private_content_boundaries() {
.unwrap()
.to_owned();
let admin_csrf = value["csrf"].as_str().unwrap().to_owned();
let input = json!({"enabled":true,"role":"publisher","prefixes":["alice-"]});
let input = json!({"enabled":true,"role":"publisher"});
let (status, _, _) = request(
app.clone(),
"PATCH",