整理文档
This commit is contained in:
1 parent
06a3d8506b
commit
0e347aad9e
26 files changed
+1552
-521
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, ¤t).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, ¤t, &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
@@ -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, ¤t.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
@@ -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", "").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",
|
||||
|
||||
Reference in new issue
Block a user