补齐后端最小功能和前端
This commit is contained in:
1 parent
a8c22b1b07
commit
a6179aba6a
56 files changed
+8126
-326
No files matched your search
+23
-15
@@ -27,6 +27,8 @@ pub enum Status {
|
||||
pub struct Metadata {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
#[serde(default)]
|
||||
pub cover: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
@@ -107,6 +109,8 @@ pub fn slug(value: &str) -> Result<&str> {
|
||||
}
|
||||
|
||||
pub fn stamp(post: &mut Post, previous: Option<&Post>, now: &str) {
|
||||
// Legacy author-supplied dates are not trusted as server publication records.
|
||||
let previous = previous.filter(|p| !p.meta.created_at.is_empty());
|
||||
post.meta.created_at = previous
|
||||
.map(|p| p.meta.created_at.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -284,19 +288,9 @@ 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
#[serde(default)]
|
||||
articles: Vec<ManifestEntry>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -322,7 +316,8 @@ fn default_entry() -> String {
|
||||
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 格式或字段无效")?;
|
||||
let index: Manifest = toml::from_str(text)
|
||||
.map_err(|error: toml::de::Error| anyhow::anyhow!("index.toml: {}", error.message()))?;
|
||||
ensure!(index.articles.len() <= 1000, "最多 1000 篇文章");
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
let mut folders = std::collections::BTreeSet::new();
|
||||
@@ -361,6 +356,11 @@ pub fn manifest(text: &str) -> Result<Vec<ManifestEntry>> {
|
||||
.all(|t| !t.trim().is_empty() && t.len() <= 80),
|
||||
"标签无效"
|
||||
);
|
||||
validate_text(&article.title)?;
|
||||
validate_text(&article.description)?;
|
||||
for tag in &article.tags {
|
||||
validate_text(tag)?;
|
||||
}
|
||||
if let Some(cover) = &article.cover {
|
||||
filename(cover)?;
|
||||
}
|
||||
@@ -450,7 +450,11 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
|
||||
ensure!(total <= MAX_SNAPSHOT, "作者仓库超过 64 MiB 配额");
|
||||
let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?;
|
||||
if is_index {
|
||||
index = Some(String::from_utf8(bytes).context("index.toml 必须为 UTF-8"));
|
||||
let text = String::from_utf8(bytes).context("index.toml 必须为 UTF-8");
|
||||
if let Ok(value) = &text {
|
||||
validate_text(value)?;
|
||||
}
|
||||
index = Some(text);
|
||||
continue;
|
||||
}
|
||||
let folder = grouped.entry(format!("posts/{}", parts[1])).or_default();
|
||||
@@ -482,9 +486,11 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
|
||||
let index = match index {
|
||||
Some(Ok(text)) => match manifest(&text) {
|
||||
Ok(index) => index,
|
||||
Err(error) => return Ok(Scan::Pending(error.to_string())),
|
||||
Err(error) => return Ok(Scan::Pending(error.to_string().chars().take(512).collect())),
|
||||
},
|
||||
Some(Err(error)) => return Ok(Scan::Pending(error.to_string())),
|
||||
Some(Err(error)) => {
|
||||
return Ok(Scan::Pending(error.to_string().chars().take(512).collect()));
|
||||
}
|
||||
None => return Ok(Scan::Pending("缺少 index.toml".into())),
|
||||
};
|
||||
let result = build_posts(index, &mut grouped, commit);
|
||||
@@ -495,7 +501,7 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
|
||||
revisions: BTreeMap::new(),
|
||||
pending: BTreeMap::new(),
|
||||
})),
|
||||
Err(error) => Ok(Scan::Pending(error.to_string())),
|
||||
Err(error) => Ok(Scan::Pending(error.to_string().chars().take(512).collect())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +565,7 @@ fn build_posts(
|
||||
}
|
||||
}
|
||||
pages.insert(name, text.clone());
|
||||
ensure!(pages.len() <= 64, "每篇文章最多 64 个可达页面");
|
||||
}
|
||||
ensure!(
|
||||
images
|
||||
@@ -577,6 +584,7 @@ fn build_posts(
|
||||
Post {
|
||||
meta: Metadata {
|
||||
title: article.title,
|
||||
author: String::new(),
|
||||
slug: id.clone(),
|
||||
description: article.description,
|
||||
tags: article.tags,
|
||||
|
||||
+70
-5
@@ -46,13 +46,35 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
|
||||
.await?;
|
||||
}
|
||||
if db
|
||||
.query("SELECT id FROM blog_articles LIMIT 1")
|
||||
.query("SELECT id, created_at 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?;
|
||||
// Only this rebuildable table changes shape; no identity/interaction data lives here.
|
||||
if db
|
||||
.query("SELECT id FROM blog_articles LIMIT 1")
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
db.query("DROP TABLE blog_articles").await?;
|
||||
}
|
||||
db.query("CREATE TABLE blog_articles (id TEXT PRIMARY KEY, author TEXT, article_id TEXT, title TEXT, description TEXT, status TEXT, created_at TEXT, published_at TEXT, updated_at TEXT, path TEXT, format TEXT, cover TEXT, entry TEXT, git_commit TEXT)").await?;
|
||||
}
|
||||
if db.query("SELECT id FROM blog_pages LIMIT 1").await.is_err() {
|
||||
db.query("CREATE TABLE blog_pages (id TEXT, name TEXT, path TEXT, git_commit TEXT)")
|
||||
.await?;
|
||||
}
|
||||
if db
|
||||
.query("SELECT id FROM blog_article_tags LIMIT 1")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
db.query("CREATE TABLE blog_article_tags (id TEXT, tag TEXT)")
|
||||
.await?;
|
||||
}
|
||||
db.query("BEGIN").await?;
|
||||
db.query("DELETE FROM blog_pages").await?;
|
||||
db.query("DELETE FROM blog_article_tags").await?;
|
||||
db.query("DELETE FROM blog_articles").await?;
|
||||
for post in snapshot.posts.values() {
|
||||
let meta = &post.meta;
|
||||
@@ -60,14 +82,36 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
|
||||
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 (")
|
||||
let q = Query::new("INSERT INTO blog_articles (id, author, article_id, title, description, status, created_at, published_at, updated_at, path, format, cover, entry, 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(meta.created_at.as_str()).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(meta.cover.as_deref().unwrap_or("")).push(", ").bind(post.entry.as_str()).push(", ")
|
||||
.bind(post.git_commit.as_str()).push(")");
|
||||
db.query(q.as_str()).await?;
|
||||
let folder = post.path.rsplit_once('/').context("正文路径无效")?.0;
|
||||
for name in post.pages.keys() {
|
||||
let q = Query::new("INSERT INTO blog_pages (id, name, path, git_commit) VALUES (")
|
||||
.bind(meta.slug.as_str())
|
||||
.push(", ")
|
||||
.bind(name.as_str())
|
||||
.push(", ")
|
||||
.bind(format!("{folder}/{name}").as_str())
|
||||
.push(", ")
|
||||
.bind(post.git_commit.as_str())
|
||||
.push(")");
|
||||
db.query(q.as_str()).await?;
|
||||
}
|
||||
for tag in &meta.tags {
|
||||
let q = Query::new("INSERT INTO blog_article_tags (id, tag) VALUES (")
|
||||
.bind(meta.slug.as_str())
|
||||
.push(", ")
|
||||
.bind(tag.as_str())
|
||||
.push(")");
|
||||
db.query(q.as_str()).await?;
|
||||
}
|
||||
}
|
||||
db.query("DELETE FROM blog_tags").await?;
|
||||
db.query("DELETE FROM blog_posts").await?;
|
||||
@@ -169,7 +213,28 @@ pub async fn consistent(snapshot: &Snapshot) -> Result<bool> {
|
||||
.iter()
|
||||
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
|
||||
.collect();
|
||||
let matches = !articles.truncated
|
||||
let page_count = db.query("SELECT count(*) FROM blog_pages").await?;
|
||||
let all_tag_count = db.query("SELECT count(*) FROM blog_article_tags").await?;
|
||||
let expected_pages = snapshot
|
||||
.posts
|
||||
.values()
|
||||
.map(|p| p.pages.len())
|
||||
.sum::<usize>() as u64;
|
||||
let expected_all_tags = snapshot
|
||||
.posts
|
||||
.values()
|
||||
.map(|p| p.meta.tags.len())
|
||||
.sum::<usize>() as u64;
|
||||
let count_value = |result: &chusql_driver::QueryResult| {
|
||||
result
|
||||
.rows
|
||||
.first()
|
||||
.and_then(|r| r.first())
|
||||
.and_then(|v| v.as_u64())
|
||||
};
|
||||
let matches = count_value(&page_count) == Some(expected_pages)
|
||||
&& count_value(&all_tag_count) == Some(expected_all_tags)
|
||||
&& !articles.truncated
|
||||
&& articles.rows.len() == expected_articles.len()
|
||||
&& actual_articles == expected_articles
|
||||
&& !result.truncated
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn page(
|
||||
.pages
|
||||
.get(&name)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "页面不存在".into()))?;
|
||||
app.views.fetch_add(1, Ordering::Relaxed);
|
||||
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<_>>(),
|
||||
|
||||
+2
-1
@@ -315,6 +315,7 @@ pub async fn import_authors(
|
||||
candidate.posts.retain(|_, post| post.author != *identity);
|
||||
for (id, mut post) in snapshot.posts {
|
||||
post.author = identity.clone();
|
||||
post.meta.author = identity.clone();
|
||||
// Neither username nor local ID allows '~', so this is collision-free.
|
||||
let key = format!("{identity}~{id}");
|
||||
post.meta.slug = key.clone();
|
||||
@@ -327,7 +328,7 @@ pub async fn import_authors(
|
||||
.posts
|
||||
.values()
|
||||
.map(|p| {
|
||||
p.markdown.len()
|
||||
p.pages.values().map(String::len).sum::<usize>()
|
||||
+ p.assets.iter().map(|a| a.data.len() * 3 / 4).sum::<usize>()
|
||||
})
|
||||
.sum();
|
||||
|
||||
+251
-164
@@ -4,11 +4,46 @@ use axum::http::Request;
|
||||
use std::collections::BTreeMap;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn markdown(key: &str, status: &str, body: &str) -> String {
|
||||
fn manifest(id: &str, folder: &str, public: bool) -> String {
|
||||
format!(
|
||||
"---\ntitle: Test\nslug: {key}\ndate: '2026-10-15'\nupdated: '2026-10-15'\nstatus: {status}\ntags: [Rust]\ndescription: Test article\n---\n\n{body}\n"
|
||||
"[[articles]]\nid = {id:?}\nfolder = {folder:?}\ntitle = \"Test\"\ndescription = \"Test article\"\ntags = [\"Rust\"]\npublic = {public}\n"
|
||||
)
|
||||
}
|
||||
fn ready(outcome: content::Scan) -> Snapshot {
|
||||
match outcome {
|
||||
content::Scan::Ready(snapshot) => snapshot,
|
||||
content::Scan::Pending(reason) => panic!("unexpected staged index: {reason}"),
|
||||
}
|
||||
}
|
||||
fn test_post(id: &str, public: bool, body: &str) -> content::Post {
|
||||
content::Post {
|
||||
meta: content::Metadata {
|
||||
title: "Test".into(),
|
||||
author: "alice".into(),
|
||||
slug: id.into(),
|
||||
date: "2026-10-16T00:00:00Z".into(),
|
||||
updated: "2026-10-16T00:00:00Z".into(),
|
||||
created_at: "2026-10-16T00:00:00Z".into(),
|
||||
status: if public {
|
||||
Status::Published
|
||||
} else {
|
||||
Status::Draft
|
||||
},
|
||||
tags: vec!["Rust".into()],
|
||||
description: "Test article".into(),
|
||||
cover: None,
|
||||
},
|
||||
markdown: body.into(),
|
||||
pages: BTreeMap::from([("main.md".into(), body.into())]),
|
||||
entry: "main.md".into(),
|
||||
assets: vec![],
|
||||
author: "alice".into(),
|
||||
article_id: id.into(),
|
||||
path: "posts/first/main.md".into(),
|
||||
git_commit: "test".into(),
|
||||
format: "markdown".into(),
|
||||
}
|
||||
}
|
||||
async fn commit(
|
||||
repo: &std::path::Path,
|
||||
parent: Option<&str>,
|
||||
@@ -61,7 +96,7 @@ async fn commit_bytes(
|
||||
.into()
|
||||
}
|
||||
#[test]
|
||||
fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
|
||||
fn rejects_active_content_and_unsafe_links_but_keeps_inert_code() {
|
||||
for body in [
|
||||
"<script>alert(1)</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
@@ -72,104 +107,127 @@ fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
|
||||
"a\u{202e}exe",
|
||||
"[x](//evil.test)",
|
||||
"[x](file:///etc/passwd)",
|
||||
"[x](../other/main.md)",
|
||||
"[x](%2e%2e%2fsecret.md)",
|
||||
"[x](a.md?script=1)",
|
||||
] {
|
||||
assert!(
|
||||
content::parse(markdown("alice-test", "published", body), vec![]).is_err(),
|
||||
content::validate_markdown(body, &[]).is_err(),
|
||||
"accepted {body}"
|
||||
);
|
||||
}
|
||||
assert!(content::parse(markdown("../escape", "published", "Hello"), vec![]).is_err());
|
||||
assert!(
|
||||
content::parse(
|
||||
markdown(
|
||||
"alice-test",
|
||||
"published",
|
||||
&"x".repeat(content::MAX_MARKDOWN)
|
||||
),
|
||||
vec![]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
content::parse(
|
||||
markdown(
|
||||
"alice-test",
|
||||
"published",
|
||||
"```html\n<script>alert(1)</script>\n```\n[valid](https://example.com)"
|
||||
),
|
||||
vec![]
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
content::decode_asset(&content::Asset {
|
||||
name: "hidden.svg".into(),
|
||||
data: "PHN2Zz4=".into()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
content::decode_asset(&content::Asset {
|
||||
name: "fake.png".into(),
|
||||
data: "PHNjcmlwdD4=".into()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
for body in [
|
||||
"[next](next.md#chapter)",
|
||||
"[next](./next.markdown)",
|
||||
"```html\n<script>alert(1)</script>\n```\n[valid](https://example.com)",
|
||||
] {
|
||||
assert!(content::validate_markdown(body, &[]).is_ok());
|
||||
}
|
||||
for (name, data) in [("hidden.svg", "PHN2Zz4="), ("fake.png", "PHNjcmlwdD4=")] {
|
||||
assert!(
|
||||
content::decode_asset(&content::Asset {
|
||||
name: name.into(),
|
||||
data: data.into()
|
||||
})
|
||||
.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}",
|
||||
fn toml_manifest_limits_author_fields_and_defaults_entry_to_main() {
|
||||
let text = manifest("first", "posts/writing", true);
|
||||
let index = content::manifest(&text).unwrap();
|
||||
assert_eq!(index[0].id, "first");
|
||||
assert_eq!(index[0].entry, "main.md");
|
||||
for input in [
|
||||
format!("{text}author = \"master\""),
|
||||
format!("{text}date = \"2000-01-01\""),
|
||||
format!("{text}updated = \"2000-01-01\""),
|
||||
format!("{text}entry = \"../secret.md\""),
|
||||
format!("{text}entry = \".gitignore\""),
|
||||
format!("{text}entry = \"cert.pem\""),
|
||||
format!("{text}{}", manifest("first", "posts/second", true)),
|
||||
format!("{text}{}", manifest("second", "posts/writing", true)),
|
||||
manifest("../escape", "posts/a", true),
|
||||
manifest("first", "posts/../escape", true),
|
||||
"".into(),
|
||||
"[[articles".into(),
|
||||
"articles = 1".into(),
|
||||
" ".repeat(65537),
|
||||
] {
|
||||
assert!(content::manifest(text).is_err(), "accepted {text}");
|
||||
assert!(content::manifest(&input).is_err(), "accepted {input}");
|
||||
}
|
||||
assert!(content::manifest(&" ".repeat(65537)).is_err());
|
||||
assert!(ssh::author_repository(std::path::Path::new("/private"), "../bob").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_manifest_controls_publication_and_validates_unlisted_files_and_history() {
|
||||
async fn git_stages_invalid_index_but_rejects_unsafe_files_and_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = content::init(dir.path()).await.unwrap();
|
||||
let zero = "0000000000000000000000000000000000000000";
|
||||
let body = markdown("first", "published", "Hello");
|
||||
let index = manifest("first", "posts/writing", true);
|
||||
let files = [
|
||||
("index.txt", "100644", "first posts/writing index.md\n"),
|
||||
("posts/writing/index.md", "100644", body.as_str()),
|
||||
("index.toml", "100644", index.as_str()),
|
||||
("posts/writing/main.md", "100644", "Hello"),
|
||||
];
|
||||
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 snapshot = ready(ssh::validate_change(&repo, zero, &first).await.unwrap());
|
||||
assert_eq!(snapshot.posts["first"].path, "posts/writing/main.md");
|
||||
let removed = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[("index.txt", "100644", ""), files[1]],
|
||||
&[("index.toml", "100644", "articles = []"), files[1]],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &removed)
|
||||
.await
|
||||
.unwrap()
|
||||
ready(ssh::validate_change(&repo, &first, &removed).await.unwrap())
|
||||
.posts
|
||||
.is_empty()
|
||||
);
|
||||
// Unlisted content is inert but must never bypass the allowlist or validation.
|
||||
let missing = commit(&repo, Some(&first), &[files[0]]).await;
|
||||
assert!(
|
||||
ready(content::scan_at(&repo, &missing).await.unwrap())
|
||||
.posts
|
||||
.is_empty()
|
||||
);
|
||||
for bad_index in ["[[articles", "articles = 1"] {
|
||||
let candidate = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[("index.toml", "100644", bad_index), files[1]],
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
ssh::validate_change(&repo, &first, &candidate)
|
||||
.await
|
||||
.unwrap(),
|
||||
content::Scan::Pending(_)
|
||||
));
|
||||
}
|
||||
let malicious_staged = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[
|
||||
("index.toml", "100644", "[[articles"),
|
||||
(
|
||||
"posts/writing/main.md",
|
||||
"100644",
|
||||
"<script>alert(1)</script>",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &malicious_staged)
|
||||
.await
|
||||
.is_err(),
|
||||
"broken TOML must not bypass content security checks"
|
||||
);
|
||||
let no_index = commit(&repo, Some(&first), &[files[1]]).await;
|
||||
assert!(matches!(
|
||||
content::scan_at(&repo, &no_index).await.unwrap(),
|
||||
content::Scan::Pending(_)
|
||||
));
|
||||
for (path, mode, value) in [
|
||||
(".gitignore", "100644", "secret"),
|
||||
("posts/writing/cert.pem", "100644", "cert"),
|
||||
@@ -179,13 +237,13 @@ async fn git_manifest_controls_publication_and_validates_unlisted_files_and_hist
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
),
|
||||
(
|
||||
"posts/writing/hidden.md",
|
||||
"posts/unlisted/hidden.md",
|
||||
"100644",
|
||||
"<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/other/main.md", "120000", "/etc/passwd"),
|
||||
("posts/other/main.md", "100755", "executable"),
|
||||
("posts/writing/payload.bin", "100644", "binary"),
|
||||
] {
|
||||
let candidate = commit(
|
||||
@@ -201,74 +259,31 @@ async fn git_manifest_controls_publication_and_validates_unlisted_files_and_hist
|
||||
"accepted {path}"
|
||||
);
|
||||
}
|
||||
let missing = commit(
|
||||
let oversize = "x".repeat(content::MAX_MARKDOWN + 1);
|
||||
let candidate = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[("index.txt", "100644", "first posts/missing index.md")],
|
||||
&[files[0], ("posts/writing/main.md", "100644", &oversize)],
|
||||
)
|
||||
.await;
|
||||
assert!(content::scan_at(&repo, &missing).await.is_err());
|
||||
let duplicate_body = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[
|
||||
files[0],
|
||||
files[1],
|
||||
("posts/writing/index.txt", "100644", body.as_str()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
content::scan_at(&repo, &duplicate_body)
|
||||
assert!(
|
||||
ssh::validate_change(&repo, &first, &candidate)
|
||||
.await
|
||||
.unwrap()
|
||||
.posts["first"]
|
||||
.path,
|
||||
"posts/writing/index.md"
|
||||
.is_err()
|
||||
);
|
||||
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),
|
||||
&[
|
||||
("index.txt", "100644", "first posts/writing index.txt"),
|
||||
("posts/writing/index.txt", "100644", body.as_str()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
content::scan_at(&repo, &plain).await.unwrap().posts["first"].format,
|
||||
"text"
|
||||
);
|
||||
let draft_body = markdown("first", "draft", "Secret");
|
||||
let draft = commit(
|
||||
&repo,
|
||||
Some(&first),
|
||||
&[
|
||||
("index.txt", "100644", "first posts/writing index.markdown"),
|
||||
("posts/writing/index.markdown", "100644", &draft_body),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
content::persist(dir.path(), &content::scan_at(&repo, &draft).await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let public: Snapshot = serde_json::from_slice(
|
||||
&tokio::fs::read(dir.path().join("published.json"))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(public.posts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images() {
|
||||
async fn multi_page_graph_and_cover_exclude_unlinked_documents_and_assets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = content::init(dir.path()).await.unwrap();
|
||||
let body = markdown("first", "published", "").replace("slug: first\n", "");
|
||||
let index = format!(
|
||||
"{}entry = \"article.markdown\"\ncover = \"cover.png\"\n",
|
||||
manifest("first", "posts/writing", true)
|
||||
);
|
||||
let mut image = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgb8(1, 1)
|
||||
.write_to(&mut image, image::ImageFormat::Png)
|
||||
@@ -277,60 +292,93 @@ async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images()
|
||||
&repo,
|
||||
None,
|
||||
&[
|
||||
("index.toml", "100644", index.as_bytes()),
|
||||
(
|
||||
"index.txt",
|
||||
"posts/writing/article.markdown",
|
||||
"100644",
|
||||
b"first posts/writing article.markdown\n",
|
||||
b"[Next](chapter.md#part)",
|
||||
),
|
||||
(
|
||||
"posts/writing/chapter.md",
|
||||
"100644",
|
||||
b"[Back](./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/figure.png", "100644", image.get_ref()),
|
||||
("posts/writing/private.png", "100644", image.get_ref()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let snapshot = content::scan_at(&repo, &commit).await.unwrap();
|
||||
let snapshot = ready(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_eq!(post.pages.len(), 2);
|
||||
assert_eq!(post.assets.len(), 2);
|
||||
assert!(!post.assets.iter().any(|a| a.name == "private.png"));
|
||||
assert!(
|
||||
!serde_json::to_string(&snapshot)
|
||||
.unwrap()
|
||||
.contains("Unselected notes")
|
||||
);
|
||||
let mut draft = snapshot.clone();
|
||||
draft.posts.get_mut("first").unwrap().meta.status = Status::Draft;
|
||||
content::persist(dir.path(), &draft).await.unwrap();
|
||||
let public: Snapshot =
|
||||
serde_json::from_slice(&std::fs::read(dir.path().join("published.json")).unwrap()).unwrap();
|
||||
assert!(public.posts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_server_assigned_and_only_change_with_article_content() {
|
||||
let mut post = test_post("first", false, "Hello");
|
||||
post.meta.date.clear();
|
||||
post.meta.created_at.clear();
|
||||
post.meta.updated.clear();
|
||||
content::stamp(&mut post, None, "2026-10-16T00:00:00Z");
|
||||
assert_eq!(post.meta.date, "");
|
||||
let initial = post.clone();
|
||||
post.meta.status = Status::Published;
|
||||
content::stamp(&mut post, Some(&initial), "2026-10-16T01:00:00Z");
|
||||
assert_eq!(post.meta.date, "2026-10-16T01:00:00Z");
|
||||
assert_eq!(post.meta.created_at, "2026-10-16T00:00:00Z");
|
||||
let published = post.clone();
|
||||
post.git_commit = "another unrelated commit".into();
|
||||
content::stamp(&mut post, Some(&published), "2026-10-16T02:00:00Z");
|
||||
assert_eq!(post.meta.updated, published.meta.updated);
|
||||
let unchanged = post.clone();
|
||||
post.pages
|
||||
.insert("chapter.md".into(), "Changed second page".into());
|
||||
content::stamp(&mut post, Some(&unchanged), "2026-10-16T03:00:00Z");
|
||||
assert_eq!(post.meta.updated, "2026-10-16T03:00:00Z");
|
||||
assert_eq!(post.meta.date, published.meta.date);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_recovery() {
|
||||
async fn author_import_preserves_live_articles_on_bad_index_and_recovers_after_fix() {
|
||||
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_index = manifest("first", "posts/a", false);
|
||||
let b_index = manifest("first", "posts/b", true);
|
||||
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),
|
||||
("index.toml", "100644", &a_index),
|
||||
("posts/a/main.md", "100644", "Alice private body"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let b_files = [
|
||||
("index.toml", "100644", b_index.as_str()),
|
||||
("posts/b/main.md", "100644", "Bob public body"),
|
||||
];
|
||||
let b = commit(&bob, None, &b_files).await;
|
||||
content::git(&alice, &["update-ref", "refs/heads/main", &a], None)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -340,8 +388,7 @@ async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_
|
||||
assert!(
|
||||
content::git(&alice, &["cat-file", "-t", &b], None)
|
||||
.await
|
||||
.is_err(),
|
||||
"Bob objects leaked into Alice repository"
|
||||
.is_err()
|
||||
);
|
||||
let data = dir.path().join("api");
|
||||
let names = vec!["alice".into(), "bob".into()];
|
||||
@@ -351,28 +398,49 @@ async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_
|
||||
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(
|
||||
let broken = commit(
|
||||
&bob,
|
||||
Some(&b),
|
||||
&[
|
||||
("index.txt", "100644", ""),
|
||||
("posts/b/index.md", "100644", &b_body),
|
||||
("index.toml", "100644", "[[articles"),
|
||||
("posts/b/main.md", "100644", "Changed pending body"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
content::git(&bob, &["update-ref", "refs/heads/main", &broken], None)
|
||||
.await
|
||||
.unwrap();
|
||||
let staged = ssh::import_authors(&data, &ingress, &first, &names)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(staged.commit, first.commit);
|
||||
assert_eq!(staged.posts["bob~first"], first.posts["bob~first"]);
|
||||
assert_eq!(staged.pending["bob"].commit, broken);
|
||||
let staged_again = ssh::import_authors(&data, &ingress, &staged, &names)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(staged_again.pending, staged.pending);
|
||||
let fixed = commit(&bob, Some(&broken), &b_files).await;
|
||||
content::git(&bob, &["update-ref", "refs/heads/main", &fixed], None)
|
||||
.await
|
||||
.unwrap();
|
||||
let repaired = ssh::import_authors(&data, &ingress, &staged, &names)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repaired.pending.is_empty());
|
||||
assert_eq!(
|
||||
repaired.posts["bob~first"].meta.updated,
|
||||
first.posts["bob~first"].meta.updated
|
||||
);
|
||||
let removed = commit(&bob, Some(&fixed), &[("index.toml", "100644", &b_index)]).await;
|
||||
content::git(&bob, &["update-ref", "refs/heads/main", &removed], None)
|
||||
.await
|
||||
.unwrap();
|
||||
let next = ssh::import_authors(&data, &ingress, &first, &names)
|
||||
let next = ssh::import_authors(&data, &ingress, &repaired, &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();
|
||||
@@ -476,8 +544,8 @@ async fn app(dir: &std::path::Path) -> Arc<App> {
|
||||
let accounts = accounts::load(dir, &config, grants.repository)
|
||||
.await
|
||||
.unwrap();
|
||||
let published = content::parse(markdown("alice-public", "published", "Hello"), vec![]).unwrap();
|
||||
let draft = content::parse(markdown("alice-secret", "draft", "Secret"), vec![]).unwrap();
|
||||
let published = test_post("alice-public", true, "Hello");
|
||||
let draft = test_post("alice-secret", false, "Secret");
|
||||
Arc::new(App {
|
||||
data: dir.into(),
|
||||
ssh_config: config,
|
||||
@@ -489,6 +557,7 @@ async fn app(dir: &std::path::Path) -> Arc<App> {
|
||||
snapshot: RwLock::new(Snapshot {
|
||||
commit: "test".into(),
|
||||
revisions: BTreeMap::new(),
|
||||
pending: BTreeMap::new(),
|
||||
posts: BTreeMap::from([
|
||||
("alice-public".into(), published),
|
||||
("alice-secret".into(), draft),
|
||||
@@ -680,6 +749,24 @@ async fn registration_login_grants_revocation_and_private_content_boundaries() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
let (status, _, value) = request(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/posts/alice-public/pages/main.md",
|
||||
Value::Null,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(value["markdown"], "Hello");
|
||||
for path in [
|
||||
"/api/posts/alice-secret/pages/main.md",
|
||||
"/api/posts/alice-public/pages/unlinked.md",
|
||||
] {
|
||||
let (status, _, _) = request(app.clone(), "GET", path, Value::Null, None, None).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
let (_, _, value) = request(app.clone(), "GET", "/api/stats", Value::Null, None, None).await;
|
||||
assert_eq!(value["published_posts"], 1);
|
||||
let (status, _, _) = request(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.env
|
||||
.env.*
|
||||
tests
|
||||
@@ -0,0 +1,4 @@
|
||||
API_BASE_URL=http://127.0.0.1:3001
|
||||
SITE_TITLE=ChuBlog
|
||||
PUBLIC_ORIGIN=http://127.0.0.1:4321
|
||||
SSH_PORT=2222
|
||||
@@ -0,0 +1,9 @@
|
||||
# 前端约定
|
||||
|
||||
首页只承担施工说明与导航,标题、进度文字、入口和页脚全部居中。大标题使用用户指定的“个人博客正在施工中”,只列真实已完成和仍未完成的功能。
|
||||
|
||||
颜色为白色 `#ffffff`、正文 `#202a2f`、次要文字 `#64727a`、分隔线 `#dce3e7`、浅底 `#f5f7f8` 和强调色 `#315e75`。使用系统中文无衬线字体,不从外部字体服务加载资源。标题靠字号和留白区分层次,不使用渐变、大幅插画或装饰标签。
|
||||
|
||||
阅读页和表单按阅读习惯左对齐。文章列表使用自然分行,标题及元信息比封面优先。桌面阅读宽度最多 720px,移动端至少保留 20px 边距。页面提供键盘焦点、显式标签、错误信息和真实空状态。
|
||||
|
||||
设计复核时移除了初始方案中的暖色纸张和陶土强调色,改用中性白底及克制的蓝灰,避免套用通用博客模板。唯一醒目的元素是施工页的大标题。
|
||||
@@ -0,0 +1,20 @@
|
||||
ARG NODE_IMAGE=node:24-bookworm-slim
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY astro.config.mjs tsconfig.json ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --omit=dev --no-audit --no-fund
|
||||
|
||||
FROM ${NODE_IMAGE}
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321 ASTRO_TELEMETRY_DISABLED=1
|
||||
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
USER node
|
||||
EXPOSE 4321
|
||||
CMD ["node", "dist/server/entry.mjs"]
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
session: false,
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
server: { port: 4321 },
|
||||
build: { inlineStylesheets: 'never' },
|
||||
vite: {
|
||||
build: { assetsInlineLimit: 0 },
|
||||
server: {
|
||||
proxy: { '/api': process.env.API_BASE_URL || 'http://127.0.0.1:3001' },
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+5772
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "chublog-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.22.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --host 127.0.0.1",
|
||||
"build": "ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build",
|
||||
"start": "node dist/server/entry.mjs",
|
||||
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "11.1.8",
|
||||
"astro": "7.4.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"sanitize-html": "^2.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#315e75"/><path d="M32 15a13 13 0 1 0 0 18" fill="none" stroke="white" stroke-width="4" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 222 B |
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
interface Props { title?: string; description?: string; home?: boolean }
|
||||
const { title, description = '文章与随笔。', home = false } = Astro.props;
|
||||
const site = process.env.SITE_TITLE || 'ChuBlog';
|
||||
const path = Astro.url.pathname;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title ? `${title} · ${site}` : site}</title>
|
||||
</head>
|
||||
<body class:list={{ 'home-page': home }}>
|
||||
<a class="skip-link" href="#main">跳到正文</a>
|
||||
<header class="site-header">
|
||||
<a class="wordmark" href="/" aria-label={`${site} 首页`}>{site}</a>
|
||||
<nav class="site-nav" aria-label="主导航">
|
||||
<a href="/" aria-current={path === '/' ? 'page' : undefined}>首页</a>
|
||||
<a href="/articles" aria-current={path.startsWith('/articles') || path.startsWith('/posts/') ? 'page' : undefined}>文章</a>
|
||||
<a href="/login" data-account-link aria-current={path === '/account' || path === '/login' ? 'page' : undefined}>登录</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="main" tabindex="-1"><slot /></main>
|
||||
<footer class="site-footer"><span>{site}</span><span>© {new Date().getFullYear()}</span></footer>
|
||||
<script>import '../scripts/client';</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface Metadata {
|
||||
title: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
cover: string | null;
|
||||
created_at: string;
|
||||
date: string;
|
||||
updated: string;
|
||||
status: 'published' | 'draft';
|
||||
}
|
||||
export interface DocumentLink { name: string; url: string }
|
||||
export interface Article {
|
||||
meta: Metadata;
|
||||
markdown: string;
|
||||
format: 'markdown' | 'text';
|
||||
entry?: string;
|
||||
name?: string;
|
||||
pages: DocumentLink[];
|
||||
assets: DocumentLink[];
|
||||
}
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number) { super('文章服务暂时不可用'); }
|
||||
}
|
||||
|
||||
export async function readApi<T>(path: string): Promise<T> {
|
||||
const base = process.env.API_BASE_URL || 'http://127.0.0.1:3001';
|
||||
const response = await fetch(new URL(path, base), {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new ApiError(response.status);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export const postUrl = (slug: string) => `/posts/${encodeURIComponent(slug)}`;
|
||||
export const pageUrl = (slug: string, name: string, entry?: string) =>
|
||||
name === entry ? postUrl(slug) : `${postUrl(slug)}/${encodeURIComponent(name)}`;
|
||||
export const assetUrl = (slug: string, name: string) =>
|
||||
`/api/assets/${encodeURIComponent(slug)}/${encodeURIComponent(name)}`;
|
||||
export function displayDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Asia/Shanghai',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { assetUrl, pageUrl, type Article } from './api';
|
||||
|
||||
export function renderMarkdown(article: Article, slug: string): string {
|
||||
const pages = new Set(article.pages.map((page) => page.name));
|
||||
const assets = new Set(article.assets.map((asset) => asset.name));
|
||||
const parser = new MarkdownIt({ html: false, linkify: false, typographer: false });
|
||||
const normalLink = parser.renderer.rules.link_open || ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options));
|
||||
parser.renderer.rules.link_open = (tokens, index, options, env, self) => {
|
||||
const token = tokens[index];
|
||||
const href = token.attrGet('href') || '';
|
||||
if (!/^(?:https?:\/\/|mailto:|\/|#)/i.test(href)) {
|
||||
const [path, ...fragment] = href.split('#');
|
||||
const name = path.replace(/^\.\//, '');
|
||||
token.attrSet('href', pages.has(name)
|
||||
? pageUrl(slug, name, article.entry) + (fragment.length ? `#${fragment.join('#')}` : '') : '#');
|
||||
}
|
||||
if (/^https?:\/\//i.test(href)) token.attrSet('rel', 'noopener noreferrer');
|
||||
return normalLink(tokens, index, options, env, self);
|
||||
};
|
||||
const normalImage = parser.renderer.rules.image!;
|
||||
parser.renderer.rules.image = (tokens, index, options, env, self) => {
|
||||
const token = tokens[index];
|
||||
const name = (token.attrGet('src') || '').split('#')[0].replace(/^\.\//, '');
|
||||
if (!assets.has(name)) return '';
|
||||
token.attrSet('src', assetUrl(slug, name));
|
||||
token.attrSet('loading', 'lazy');
|
||||
token.attrSet('decoding', 'async');
|
||||
return normalImage(tokens, index, options, env, self);
|
||||
};
|
||||
const used = new Map<string, number>();
|
||||
parser.core.ruler.push('heading-anchors', (state) => {
|
||||
state.tokens.forEach((token, index) => {
|
||||
if (token.type !== 'heading_open') return;
|
||||
const text = state.tokens[index + 1]?.content || '';
|
||||
const base = text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-|-$/g, '') || 'section';
|
||||
const count = used.get(base) || 0;
|
||||
used.set(base, count + 1);
|
||||
token.attrSet('id', count ? `${base}-${count}` : base);
|
||||
const level = Math.min(Number(token.tag.slice(1)) + 1, 6);
|
||||
token.tag = `h${level}`;
|
||||
const close = state.tokens[index + 2];
|
||||
if (close?.type === 'heading_close') close.tag = token.tag;
|
||||
});
|
||||
});
|
||||
return sanitizeHtml(parser.render(article.markdown), {
|
||||
allowedTags: ['p', 'a', 'em', 'strong', 's', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'br', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'title', 'rel'], img: ['src', 'alt', 'title', 'loading', 'decoding'],
|
||||
code: ['class'], ol: ['start'], h2: ['id'], h3: ['id'], h4: ['id'], h5: ['id'], h6: ['id'],
|
||||
},
|
||||
allowedClasses: { code: [/^language-[a-z0-9_-]+$/i] },
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a: (_tag, attrs) => ({ tagName: 'a', attribs: attrs.href?.startsWith('//') ? {} : attrs }),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
|
||||
export const onRequest = defineMiddleware(async (_context, next) => {
|
||||
const response = await next();
|
||||
response.headers.set('Cache-Control', 'no-store');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'same-origin');
|
||||
response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
---
|
||||
<Base title="页面不存在"><div class="empty-state page-shell"><h1>页面不存在</h1><p>检查一下地址,或从首页重新开始。</p><a class="text-link" href="/">返回首页</a></div></Base>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
const sshPort = Number(process.env.SSH_PORT || 2222);
|
||||
const hostname = new URL(process.env.PUBLIC_ORIGIN || Astro.url.origin).hostname;
|
||||
const sshConfig = `Host chublog\n HostName ${hostname}\n Port ${sshPort}\n User git\n IdentityFile ~/.ssh/id_ed25519\n IdentitiesOnly yes`;
|
||||
---
|
||||
<Base title="账户">
|
||||
<div class="account-shell">
|
||||
<header class="page-heading"><h1>账户</h1><p id="account-status" role="status">正在读取账户…</p></header>
|
||||
<div id="account-content" hidden>
|
||||
<div class="account-identity"><div><strong id="account-name"></strong><span class="role-label" id="account-role"></span></div><button id="logout" class="button button-quiet" type="button">退出登录</button></div>
|
||||
<section class="settings-section" aria-labelledby="key-heading">
|
||||
<h2 id="key-heading">SSH 公钥</h2><p id="publish-status" class="section-description"></p>
|
||||
<form id="key-form"><label for="public-key">公钥内容</label><textarea id="public-key" name="public_key" rows="4" maxlength="4096" required spellcheck="false" autocomplete="off" placeholder="ssh-ed25519 AAAA… 或 ssh-rsa AAAA…" aria-describedby="key-help"></textarea><p class="field-help" id="key-help">只粘贴 .pub 文件中的公钥,请勿上传私钥。保存后替换原公钥。</p><p class="form-status" role="status" aria-live="polite"></p><button class="button" type="submit">保存公钥</button></form>
|
||||
</section>
|
||||
<section class="settings-section" aria-labelledby="publishing-heading">
|
||||
<h2 id="publishing-heading">从本地发布文章</h2><p class="section-description">获得站长授权后,配置 SSH 并克隆自己的文章仓库。</p>
|
||||
<ol class="publishing-steps"><li><p>在本机生成密钥,并将公钥保存在上方。</p><pre><code>ssh-keygen -t ed25519</code></pre></li><li><p>在本机 ~/.ssh/config 中加入连接配置。首次连接时核对服务器指纹。</p><pre><code>{sshConfig}</code></pre></li><li><p>克隆仓库,创建索引和正文,再提交推送。</p><pre><code>git clone chublog:content.git my-articles
|
||||
cd my-articles
|
||||
# 创建 index.toml 和 posts/first/main.md
|
||||
git add index.toml posts/first
|
||||
git commit -m 'Publish article'
|
||||
git push -u origin main</code></pre></li></ol>
|
||||
<details><summary>查看 index.toml 示例</summary><pre><code>{`[[articles]]\nid = "first"\nfolder = "posts/first"\ntitle = "第一篇文章"\ndescription = "文章简介"\ntags = ["随笔"]\npublic = true\n# entry = "main.md"`}</code></pre><p class="field-help">作者和时间由系统生成。入口默认 main.md,正文不需要 Frontmatter。</p></details>
|
||||
</section>
|
||||
<section class="settings-section" aria-labelledby="password-heading"><h2 id="password-heading">修改密码</h2><form id="password-form"><label for="old-password">原密码</label><input id="old-password" type="password" name="old_password" autocomplete="current-password" required maxlength="1024" /><label for="new-password">新密码</label><input id="new-password" type="password" name="new_password" autocomplete="new-password" required maxlength="1024" /><p class="form-status" role="status" aria-live="polite"></p><button class="button button-quiet" type="submit">修改密码</button></form></section>
|
||||
<section id="admin-section" class="settings-section" aria-labelledby="admin-heading" hidden><h2 id="admin-heading">发布授权</h2><p class="section-description">公钥登记与发布授权分别检查。停用账户会同时撤销登录和发布权限。</p><p id="admin-status" class="form-status" role="status" aria-live="polite"></p><div id="admin-users" class="admin-users"></div><h3>暂存的索引</h3><div id="pending-indexes"></div></section>
|
||||
</div>
|
||||
<noscript><p class="notice">账户设置需要启用 JavaScript。</p></noscript>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { readApi, postUrl, displayDate, assetUrl, type Metadata } from '../lib/api';
|
||||
const query = (Astro.url.searchParams.get('q') || '').trim().slice(0, 100);
|
||||
const selectedTag = Astro.url.searchParams.get('tag') || '';
|
||||
let articles: Metadata[] = [];
|
||||
let failed = false;
|
||||
try { articles = (await readApi<{ posts: Metadata[] }>('/api/posts')).posts; }
|
||||
catch { failed = true; Astro.response.status = 503; }
|
||||
const tags = [...new Set(articles.flatMap((article) => article.tags))].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
const filtered = articles.filter((article) => (!selectedTag || article.tags.includes(selectedTag)) && (!query || `${article.title} ${article.description} ${article.author} ${article.tags.join(' ')}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())));
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / 15));
|
||||
const page = Math.min(totalPages, Math.max(1, Number.parseInt(Astro.url.searchParams.get('page') || '1', 10) || 1));
|
||||
const visible = filtered.slice((page - 1) * 15, page * 15);
|
||||
const urlFor = (page: number) => { const params = new URLSearchParams(); if (query) params.set('q', query); if (selectedTag) params.set('tag', selectedTag); params.set('page', String(page)); return `/articles?${params}`; };
|
||||
---
|
||||
<Base title="文章">
|
||||
<div class="page-shell">
|
||||
<header class="page-heading"><h1>文章</h1><p>写下的文字,按时间排列。</p></header>
|
||||
<form class="search-form" action="/articles" method="get" role="search">
|
||||
<label class="visually-hidden" for="search">搜索文章</label>
|
||||
<input id="search" type="search" autocomplete="off" name="q" value={query} placeholder="搜索标题、简介或标签" maxlength="100" />
|
||||
{selectedTag && <input type="hidden" name="tag" value={selectedTag} />}
|
||||
<button type="submit" class="button button-quiet">搜索</button>
|
||||
</form>
|
||||
{tags.length > 0 && <nav class="tag-filter" aria-label="按标签筛选">
|
||||
<a href="/articles" aria-current={!selectedTag ? 'true' : undefined}>全部</a>
|
||||
{tags.map((tag) => <a href={`/articles?tag=${encodeURIComponent(tag)}`} aria-current={tag === selectedTag ? 'true' : undefined}>{tag}</a>)}
|
||||
</nav>}
|
||||
{failed ? <div class="empty-state" role="status"><h2>文章暂时无法加载</h2><p>请稍后重试。</p><a class="text-link" href={Astro.url.pathname + Astro.url.search}>重新加载</a></div>
|
||||
: visible.length === 0 ? <div class="empty-state"><h2>{query || selectedTag ? '没有匹配的文章' : '还没有公开文章'}</h2><p>{query || selectedTag ? '换个关键词,或查看全部文章。' : '文章发布后会出现在这里。'}</p><a class="text-link" href={query || selectedTag ? '/articles' : '/account'}>{query || selectedTag ? '查看全部文章' : '前往账户'}</a></div>
|
||||
: <ol class="article-list">{visible.map((article) => <li>
|
||||
<article class:list={{ 'article-with-cover': article.cover }}>
|
||||
<div class="article-summary">
|
||||
<div class="article-meta"><time datetime={article.date}>{displayDate(article.date)}</time><span>{article.author}</span></div>
|
||||
<h2><a href={postUrl(article.slug)}>{article.title}</a></h2>
|
||||
{article.description && <p>{article.description}</p>}
|
||||
{article.tags.length > 0 && <div class="article-tags">{article.tags.map((tag) => <a href={`/articles?tag=${encodeURIComponent(tag)}`}>{tag}</a>)}</div>}
|
||||
</div>
|
||||
{article.cover && <a href={postUrl(article.slug)} class="article-cover" aria-label={`阅读 ${article.title}`}><img src={assetUrl(article.slug, article.cover)} alt="" width="160" height="112" loading="lazy" /></a>}
|
||||
</article>
|
||||
</li>)}</ol>}
|
||||
{totalPages > 1 && <nav class="pagination" aria-label="文章分页">{page > 1 ? <a href={urlFor(page - 1)}>上一页</a> : <span /> }<span>{page} / {totalPages}</span>{page < totalPages ? <a href={urlFor(page + 1)}>下一页</a> : <span />}</nav>}
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
---
|
||||
<Base home title="个人博客正在施工中" description="个人博客正在施工中。已完成文章阅读、账户登录与 Git 发布,其他功能逐步完善。">
|
||||
<section class="construction" aria-labelledby="construction-title">
|
||||
<h1 id="construction-title">个人博客正在施工中</h1>
|
||||
<div class="construction-progress">
|
||||
<section aria-labelledby="finished-title">
|
||||
<h2 id="finished-title">已完成</h2>
|
||||
<p>文章阅读与多页导航、用户注册与登录、SSH 公钥设置、Git 文章发布</p>
|
||||
</section>
|
||||
<section aria-labelledby="pending-title">
|
||||
<h2 id="pending-title">未完成</h2>
|
||||
<p>评论、点赞与收藏、热度推荐、内部文章、站长审核</p>
|
||||
</section>
|
||||
</div>
|
||||
<nav class="home-links" aria-label="快捷入口">
|
||||
<a class="button" href="/articles">阅读文章</a>
|
||||
<a class="text-link" href="/account">账户与发布</a>
|
||||
</nav>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
const register = Astro.url.searchParams.get('mode') === 'register';
|
||||
---
|
||||
<Base title={register ? '注册' : '登录'}>
|
||||
<div class="auth-shell">
|
||||
<header class="page-heading"><h1>{register ? '注册账户' : '登录'}</h1><p>{register ? '注册后可在账户中登记发布用的 SSH 公钥。' : '管理账户和文章发布权限。'}</p></header>
|
||||
<nav class="auth-tabs" aria-label="账户入口"><a href="/login" aria-current={!register ? 'page' : undefined}>登录</a><a href="/login?mode=register" aria-current={register ? 'page' : undefined}>注册</a></nav>
|
||||
{Astro.url.searchParams.get('changed') === '1' && <p class="notice">密码已修改,请重新登录。</p>}
|
||||
{Astro.url.searchParams.get('registered') === '1' && <p class="notice">账户已创建,请登录。</p>}
|
||||
<form id="auth-form" data-mode={register ? 'register' : 'login'}>
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" spellcheck="false" autocapitalize="none" autocomplete="username" required minlength="3" maxlength="32" pattern="[a-z][a-z0-9-]{2,31}" aria-describedby={register ? 'username-help' : undefined} />
|
||||
{register && <p class="field-help" id="username-help">3 至 32 位小写字母、数字或短横线,以字母开头。</p>}
|
||||
<label for="password">密码</label>
|
||||
<input id="password" type="password" name="password" autocomplete={register ? 'new-password' : 'current-password'} required maxlength="1024" aria-describedby={register ? 'password-help' : undefined} />
|
||||
{register && <p class="field-help" id="password-help">至少 12 个字节,请使用较长且不重复的密码。</p>}
|
||||
{register && <><label for="password-confirm">确认密码</label><input id="password-confirm" type="password" name="confirm" autocomplete="new-password" required maxlength="1024" /></>}
|
||||
<p class="form-status" role="status" aria-live="polite"></p>
|
||||
<button class="button" type="submit">{register ? '创建账户' : '登录'}</button>
|
||||
</form>
|
||||
<noscript><p class="notice">登录与注册需要启用 JavaScript。</p></noscript>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { ApiError, readApi, pageUrl, displayDate, type Article } from '../../lib/api';
|
||||
import { renderMarkdown } from '../../lib/markdown';
|
||||
const segments = (Astro.params.path || '').split('/');
|
||||
const [slug, name] = segments;
|
||||
let article: Article | undefined;
|
||||
let missing = false;
|
||||
try {
|
||||
if (segments.length > 2 || !/^[a-z0-9-]+~[a-z0-9-]+$/.test(slug || '') || (name && !/^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*\.(md|markdown|txt)$/.test(name))) throw new ApiError(404);
|
||||
article = await readApi<Article>(name ? `/api/posts/${encodeURIComponent(slug)}/pages/${encodeURIComponent(name)}` : `/api/posts/${encodeURIComponent(slug)}`);
|
||||
} catch (error) { missing = error instanceof ApiError && error.status === 404; Astro.response.status = missing ? 404 : 503; }
|
||||
let html = '';
|
||||
if (article?.format === 'markdown') html = renderMarkdown(article, slug);
|
||||
const currentPage = name || article?.entry;
|
||||
---
|
||||
<Base title={article?.meta.title || (missing ? '文章不存在' : '暂时无法读取')} description={article?.meta.description}>
|
||||
{article ? <div class="reading-shell">
|
||||
<a class="back-link" href="/articles">返回文章</a>
|
||||
<header class="article-heading">
|
||||
<div class="article-meta"><span>{article.meta.author}</span><time datetime={article.meta.date}>{displayDate(article.meta.date)}</time></div>
|
||||
<h1>{article.meta.title}</h1>
|
||||
{article.meta.description && <p class="article-description">{article.meta.description}</p>}
|
||||
{article.meta.tags.length > 0 && <div class="article-tags">{article.meta.tags.map((tag) => <a href={`/articles?tag=${encodeURIComponent(tag)}`}>{tag}</a>)}</div>}
|
||||
</header>
|
||||
{article.pages.length > 1 && <nav class="document-nav" aria-label="文章页面">{article.pages.map((page) => <a href={pageUrl(slug, page.name, article.entry)} aria-current={page.name === currentPage ? 'page' : undefined}>{page.name === (article.entry || 'main.md') ? '正文' : page.name.replace(/\.(md|markdown|txt)$/, '').replace(/[-_]/g, ' ')}</a>)}</nav>}
|
||||
{article.format === 'text' ? <pre class="plain-text">{article.markdown}</pre> : <div class="prose" set:html={html} />}
|
||||
<footer class="article-end"><p>最后修改于 <time datetime={article.meta.updated}>{displayDate(article.meta.updated)}</time></p><a class="text-link" href="/articles">查看其他文章</a></footer>
|
||||
</div> : <div class="empty-state page-shell"><h1>{missing ? '文章不存在' : '暂时无法读取文章'}</h1><p>{missing ? '文章可能已撤回,或尚未公开。' : '请稍后重试。'}</p><a class="text-link" href="/articles">返回文章列表</a></div>}
|
||||
</Base>
|
||||
@@ -0,0 +1,154 @@
|
||||
export {};
|
||||
type Role = 'reader' | 'publisher' | 'admin';
|
||||
interface Grant { enabled: boolean; role: Role; ssh_key: string | null }
|
||||
interface Account { username: string; permissions: Grant; csrf: string }
|
||||
let csrf: string | undefined;
|
||||
const roles: Record<Role, string> = { reader: '普通用户', publisher: '作者', admin: '管理员' };
|
||||
|
||||
async function api<T>(path: string, method = 'GET', data?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (data !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (csrf && method !== 'GET') headers['X-CSRF-Token'] = csrf;
|
||||
const response = await fetch(path, { method, credentials: 'same-origin', headers,
|
||||
body: data === undefined ? undefined : JSON.stringify(data), signal: AbortSignal.timeout(10000) });
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(result?.error || (response.status === 429 ? '请求过于频繁,请稍后重试。' : '操作未完成,请稍后重试。'));
|
||||
return result as T;
|
||||
}
|
||||
function showStatus(element: HTMLElement | null, message: string, error = false) {
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
element.dataset.error = String(error);
|
||||
}
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.name !== 'TimeoutError' && error.name !== 'TypeError'
|
||||
? error.message : '无法连接服务器,请检查网络后重试。';
|
||||
}
|
||||
function formAction(id: string, action: (data: FormData, form: HTMLFormElement) => Promise<void>) {
|
||||
const form = document.querySelector<HTMLFormElement>(`#${id}`);
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
|
||||
const message = form.querySelector<HTMLElement>('.form-status');
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
showStatus(message, '正在处理…');
|
||||
try { await action(new FormData(form), form); }
|
||||
catch (error) { showStatus(message, errorMessage(error), true); }
|
||||
finally { button.disabled = false; form.removeAttribute('aria-busy'); }
|
||||
});
|
||||
}
|
||||
const value = (data: FormData, name: string) => String(data.get(name) || '');
|
||||
function checkPassword(password: string) {
|
||||
const size = new TextEncoder().encode(password).length;
|
||||
if (size < 12 || size > 1024) throw new Error('密码需要在 12 至 1024 个字节之间。');
|
||||
}
|
||||
|
||||
formAction('auth-form', async (data, form) => {
|
||||
const credentials = { username: value(data, 'username'), password: value(data, 'password') };
|
||||
if (form.dataset.mode === 'register') {
|
||||
checkPassword(credentials.password);
|
||||
if (credentials.password !== value(data, 'confirm')) throw new Error('两次输入的密码不一致。');
|
||||
await api('/api/auth/register', 'POST', credentials);
|
||||
// Registration succeeded even if the following login is temporarily rate-limited.
|
||||
try { await api('/api/auth/login', 'POST', credentials); }
|
||||
catch { location.assign('/login?registered=1'); return; }
|
||||
} else { await api('/api/auth/login', 'POST', credentials); }
|
||||
form.reset();
|
||||
location.assign('/account');
|
||||
});
|
||||
|
||||
formAction('key-form', async (data, form) => {
|
||||
const key = value(data, 'public_key').trim();
|
||||
if (key.includes('PRIVATE KEY')) throw new Error('这里需要 .pub 公钥,请勿提交私钥。');
|
||||
await api('/api/account/ssh-key', 'PUT', { public_key: key });
|
||||
showStatus(form.querySelector('.form-status'), '公钥已保存。发布仍需站长授权。');
|
||||
});
|
||||
formAction('password-form', async (data, form) => {
|
||||
const password = value(data, 'new_password');
|
||||
checkPassword(password);
|
||||
await api('/api/account/password', 'PUT', { old_password: value(data, 'old_password'), new_password: password });
|
||||
form.reset();
|
||||
csrf = undefined;
|
||||
location.assign('/login?changed=1');
|
||||
});
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#logout')?.addEventListener('click', async (event) => {
|
||||
const button = event.currentTarget as HTMLButtonElement;
|
||||
button.disabled = true;
|
||||
try { await api('/api/auth/logout', 'POST', {}); csrf = undefined; location.assign('/'); }
|
||||
catch (error) { showStatus(document.querySelector('#account-status'), errorMessage(error), true); button.disabled = false; }
|
||||
});
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
async function loadAdmin(account: Account) {
|
||||
const section = document.querySelector<HTMLElement>('#admin-section')!;
|
||||
section.hidden = false;
|
||||
try {
|
||||
const [users, state] = await Promise.all([
|
||||
api<Record<string, Grant>>('/api/admin/users'),
|
||||
api<{ pending: Record<string, { commit: string; reason: string }> }>('/api/admin/status'),
|
||||
]);
|
||||
const list = document.querySelector<HTMLElement>('#admin-users')!;
|
||||
for (const [username, grant] of Object.entries(users)) {
|
||||
const form = element('form'); form.className = 'admin-user';
|
||||
form.append(element('h4', username === account.username ? `${username}(当前账户)` : username));
|
||||
if (username === account.username) { form.append(element('p', '当前管理员')); list.append(form); continue; }
|
||||
const controls = element('div'); controls.className = 'admin-user-controls';
|
||||
const select = element('select'); select.setAttribute('aria-label', `${username} 的角色`);
|
||||
for (const [role, label] of Object.entries(roles)) {
|
||||
const option = element('option', label); option.value = role; option.selected = role === grant.role; select.append(option);
|
||||
}
|
||||
const label = element('label'); const enabled = element('input'); enabled.type = 'checkbox'; enabled.checked = grant.enabled;
|
||||
label.append(enabled, document.createTextNode('启用账户'));
|
||||
const button = element('button', '保存授权'); button.className = 'button button-quiet'; button.type = 'submit';
|
||||
controls.append(select, label, button);
|
||||
const message = element('p'); message.className = 'form-status'; message.setAttribute('role', 'status');
|
||||
form.append(controls, message);
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); if (button.disabled) return; button.disabled = true;
|
||||
try { await api(`/api/admin/users/${encodeURIComponent(username)}`, 'PATCH', { enabled: enabled.checked, role: select.value }); showStatus(message, '授权已保存。'); }
|
||||
catch (error) { showStatus(message, errorMessage(error), true); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
list.append(form);
|
||||
}
|
||||
const pending = document.querySelector<HTMLElement>('#pending-indexes')!;
|
||||
const entries = Object.entries(state.pending);
|
||||
if (!entries.length) pending.append(element('p', '没有待修复的索引。'));
|
||||
for (const [author, item] of entries) {
|
||||
const row = element('div'); row.className = 'pending-item'; row.append(element('strong', `${author} / ${item.commit.slice(0, 8)}`), element('p', item.reason)); pending.append(row);
|
||||
}
|
||||
} catch (error) { showStatus(document.querySelector('#admin-status'), errorMessage(error), true); }
|
||||
}
|
||||
|
||||
async function loadAccount() {
|
||||
const content = document.querySelector<HTMLElement>('#account-content');
|
||||
try {
|
||||
const account = await api<Account>('/api/account');
|
||||
csrf = account.csrf;
|
||||
const link = document.querySelector<HTMLAnchorElement>('[data-account-link]');
|
||||
if (link) { link.href = '/account'; link.textContent = '账户'; }
|
||||
if (!content) return;
|
||||
content.hidden = false;
|
||||
showStatus(document.querySelector('#account-status'), '');
|
||||
document.querySelector('#account-name')!.textContent = account.username;
|
||||
document.querySelector('#account-role')!.textContent = roles[account.permissions.role];
|
||||
document.querySelector<HTMLTextAreaElement>('#public-key')!.value = account.permissions.ssh_key || '';
|
||||
document.querySelector('#publish-status')!.textContent = account.permissions.role === 'reader'
|
||||
? '发布文章需要登记公钥,并由站长授予发布权限。'
|
||||
: account.permissions.ssh_key ? '已获得发布权限,可以使用下方配置连接个人仓库。' : '已获得发布权限,登记公钥后即可连接个人仓库。';
|
||||
if (account.permissions.role === 'admin') await loadAdmin(account);
|
||||
} catch (error) {
|
||||
if (!content) return;
|
||||
showStatus(document.querySelector('#account-status'), errorMessage(error), true);
|
||||
const link = element('a', '前往登录'); link.href = '/login'; link.className = 'text-link';
|
||||
document.querySelector('#account-status')!.after(link);
|
||||
}
|
||||
}
|
||||
void loadAccount();
|
||||
@@ -0,0 +1,162 @@
|
||||
:root {
|
||||
--paper: #fff;
|
||||
--ink: #202a2f;
|
||||
--muted: #64727a;
|
||||
--line: #dce3e7;
|
||||
--soft: #f5f7f8;
|
||||
--accent: #315e75;
|
||||
--error: #a53432;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100svh; display: flex; flex-direction: column; }
|
||||
main { flex: 1; outline: none; }
|
||||
a { color: inherit; text-decoration: none; text-underline-offset: .24em; }
|
||||
a:hover { color: var(--accent); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, select, summary { cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .55; }
|
||||
button, input, textarea, select { touch-action: manipulation; }
|
||||
[hidden] { display: none !important; }
|
||||
::selection { background: #dbe9f0; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1, h2, h3 { overflow-wrap: anywhere; text-wrap: balance; }
|
||||
p { line-height: 1.8; }
|
||||
.skip-link { position: fixed; left: 16px; top: -100px; z-index: 10; padding: 10px 16px; background: var(--ink); color: white; border-radius: 4px; }
|
||||
.skip-link:focus { top: 12px; color: white; }
|
||||
.site-header { padding: 42px 24px 28px; text-align: center; }
|
||||
.wordmark { font-size: 19px; font-weight: 650; letter-spacing: -.035em; }
|
||||
.site-nav { display: flex; justify-content: center; gap: 32px; margin-top: 21px; font-size: 13px; color: var(--muted); }
|
||||
.site-nav a { padding: 5px 0; border-bottom: 1px solid transparent; }
|
||||
.site-nav a[aria-current="page"] { color: var(--ink); border-color: var(--ink); }
|
||||
.site-footer { display: flex; justify-content: center; gap: 12px; padding: 40px 24px 28px; color: var(--muted); font-size: 12px; text-align: center; }
|
||||
.button { display: inline-flex; justify-content: center; align-items: center; min-height: 44px; padding: 11px 22px; border: 1px solid var(--accent); border-radius: 5px; background: var(--accent); color: white; font-size: 14px; font-weight: 500; line-height: 1.4; }
|
||||
.button:hover { background: #25495b; border-color: #25495b; color: white; }
|
||||
.button-quiet { color: var(--ink); background: white; border-color: var(--line); }
|
||||
.button-quiet:hover { background: var(--soft); color: var(--ink); border-color: #a6b5bd; }
|
||||
.text-link { color: var(--accent); font-size: 14px; text-decoration: underline; }
|
||||
.construction { min-height: calc(100svh - 214px); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 45px 24px 92px; text-align: center; }
|
||||
.construction h1 { font-size: clamp(32px, 5.4vw, 66px); line-height: 1.35; letter-spacing: -.05em; font-weight: 500; margin: 0 0 47px; }
|
||||
.construction-progress { width: min(100%, 750px); font-size: 13px; color: var(--muted); }
|
||||
.construction-progress section + section { margin-top: 24px; }
|
||||
.construction-progress h2 { font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 9px; }
|
||||
.construction-progress p { margin-bottom: 0; line-height: 2; text-wrap: pretty; }
|
||||
.home-links { margin-top: 42px; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 25px; }
|
||||
.page-shell { width: min(100% - 48px, 820px); margin: 54px auto 65px; }
|
||||
.page-heading { margin-bottom: 34px; }
|
||||
.page-heading h1 { font-size: 34px; letter-spacing: -.045em; font-weight: 550; line-height: 1.35; margin-bottom: 13px; }
|
||||
.page-heading p { font-size: 14px; color: var(--muted); margin-bottom: 0; }
|
||||
.search-form { display: flex; gap: 10px; margin: 0 0 22px; }
|
||||
.search-form input { flex: 1; min-width: 0; margin: 0; }
|
||||
.tag-filter { display: flex; gap: 9px 20px; flex-wrap: wrap; padding-bottom: 26px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
||||
.tag-filter a { padding: 4px 0; }
|
||||
.tag-filter [aria-current="true"] { color: var(--accent); text-decoration: underline; }
|
||||
.article-list { list-style: none; padding: 0; margin: 0; }
|
||||
.article-list > li { padding: 30px 0; border-bottom: 1px solid var(--line); }
|
||||
.article-with-cover { display: flex; align-items: center; gap: 30px; }
|
||||
.article-summary { flex: 1; min-width: 0; }
|
||||
.article-meta { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; line-height: 1.5; color: var(--muted); }
|
||||
.article-list h2 { font-size: 23px; font-weight: 550; letter-spacing: -.025em; line-height: 1.5; margin: 12px 0 10px; }
|
||||
.article-list p { margin-bottom: 14px; font-size: 14px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.article-tags { display: flex; flex-wrap: wrap; gap: 8px 17px; font-size: 12px; color: var(--accent); }
|
||||
.article-tags a:hover { text-decoration: underline; }
|
||||
.article-cover { flex: 0 0 160px; }
|
||||
.article-cover img { display: block; width: 160px; height: 112px; object-fit: cover; border-radius: 4px; background: var(--soft); }
|
||||
.pagination { display: flex; justify-content: space-between; margin-top: 32px; font-size: 13px; color: var(--muted); }
|
||||
.pagination > * { min-width: 60px; text-align: center; }
|
||||
.empty-state { text-align: center; padding: 70px 15px; }
|
||||
.empty-state h1 { font-size: 30px; }
|
||||
.empty-state h2 { font-size: 20px; font-weight: 500; }
|
||||
.empty-state p { color: var(--muted); font-size: 14px; }
|
||||
.reading-shell { width: min(100% - 48px, 720px); margin: 42px auto 60px; }
|
||||
.back-link { display: inline-block; color: var(--muted); font-size: 13px; margin-bottom: 34px; }
|
||||
.article-heading { padding-bottom: 30px; border-bottom: 1px solid var(--line); }
|
||||
.article-heading h1 { font-size: clamp(28px, 4vw, 42px); font-weight: 550; letter-spacing: -.045em; line-height: 1.4; margin: 15px 0; }
|
||||
.article-description { font-size: 15px; color: var(--muted); margin-bottom: 20px; }
|
||||
.document-nav { display: flex; flex-wrap: wrap; gap: 10px 22px; font-size: 13px; padding: 20px 0 0; color: var(--muted); }
|
||||
.document-nav [aria-current="page"] { color: var(--accent); text-decoration: underline; }
|
||||
.prose { font-size: 16px; line-height: 1.95; overflow-wrap: anywhere; padding-top: 24px; }
|
||||
.prose > :first-child { margin-top: 0; }
|
||||
.prose p { line-height: 1.95; margin-bottom: 1.4em; }
|
||||
.prose h2 { font-size: 25px; margin: 1.8em 0 .8em; font-weight: 550; line-height: 1.5; }
|
||||
.prose h3 { font-size: 20px; margin: 1.7em 0 .7em; font-weight: 550; line-height: 1.5; }
|
||||
.prose h4, .prose h5, .prose h6 { font-size: 17px; margin: 1.6em 0 .7em; }
|
||||
.prose :is(h2,h3,h4,h5,h6) { scroll-margin-top: 24px; }
|
||||
.prose a { color: var(--accent); text-decoration: underline; }
|
||||
.prose img { display: block; max-width: 100%; height: auto; margin: 25px auto; border-radius: 3px; }
|
||||
.prose ul, .prose ol { padding-left: 1.7em; }
|
||||
.prose li { margin: .4em 0; }
|
||||
.prose blockquote { margin: 24px 0; padding: 1px 0 1px 20px; color: var(--muted); border-left: 2px solid #a8bbc5; }
|
||||
.prose blockquote p:last-child { margin-bottom: 0; }
|
||||
pre { font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace; font-size: 13px; line-height: 1.85; padding: 18px 20px; background: var(--soft); border-radius: 5px; overflow-x: auto; tab-size: 4; }
|
||||
code { font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace; font-size: .87em; }
|
||||
.prose :not(pre) > code { background: var(--soft); padding: .2em .4em; border-radius: 3px; }
|
||||
pre code { font-size: inherit; }
|
||||
.prose table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 14px; }
|
||||
.prose th, .prose td { padding: 9px 14px; border: 1px solid var(--line); text-align: left; }
|
||||
.prose hr { border: 0; border-top: 1px solid var(--line); margin: 32px 0; }
|
||||
.plain-text { white-space: pre-wrap; overflow-wrap: anywhere; background: transparent; font-family: inherit; font-size: 16px; padding: 28px 0; }
|
||||
.article-end { border-top: 1px solid var(--line); margin-top: 44px; padding-top: 24px; }
|
||||
.article-end p { color: var(--muted); font-size: 12px; }
|
||||
.auth-shell { width: min(100% - 48px, 370px); margin: 46px auto 75px; }
|
||||
.auth-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--line); margin-bottom: 28px; font-size: 14px; color: var(--muted); }
|
||||
.auth-tabs a { padding: 0 0 12px; }
|
||||
.auth-tabs [aria-current="page"] { border-bottom: 2px solid var(--accent); color: var(--ink); }
|
||||
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 9px; }
|
||||
input:not([type="checkbox"]), textarea, select { display: block; border: 1px solid #bfcbd2; border-radius: 4px; background: white; color: var(--ink); min-height: 44px; width: 100%; padding: 11px 12px; font-size: 14px; margin-bottom: 21px; }
|
||||
input::placeholder, textarea::placeholder { color: #74838b; }
|
||||
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
textarea { resize: vertical; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.field-help { color: var(--muted); font-size: 12px; margin: -11px 0 21px; line-height: 1.8; }
|
||||
.form-status { font-size: 13px; color: var(--accent); min-height: 20px; margin: 12px 0; overflow-wrap: anywhere; }
|
||||
.form-status[data-error="true"] { color: var(--error); }
|
||||
.auth-shell form > .button { width: 100%; }
|
||||
.notice { font-size: 13px; color: var(--muted); background: var(--soft); padding: 12px 15px; border-radius: 4px; }
|
||||
.account-shell { width: min(100% - 48px, 720px); margin: 46px auto 60px; }
|
||||
.account-identity { display: flex; gap: 20px; align-items: center; justify-content: space-between; padding: 0 0 28px; }
|
||||
.account-identity strong { font-weight: 550; font-size: 18px; }
|
||||
.role-label { color: var(--muted); font-size: 12px; margin-left: 14px; }
|
||||
.settings-section { padding: 30px 0; border-top: 1px solid var(--line); }
|
||||
.settings-section h2 { font-size: 20px; font-weight: 550; margin-bottom: 13px; }
|
||||
.settings-section h3 { font-size: 15px; font-weight: 550; margin: 30px 0 15px; }
|
||||
.section-description { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
|
||||
.publishing-steps { padding-left: 21px; font-size: 13px; }
|
||||
.publishing-steps li { padding-left: 3px; margin-bottom: 22px; }
|
||||
.publishing-steps p { margin-bottom: 12px; color: var(--muted); }
|
||||
.settings-section details summary { font-size: 13px; padding: 5px 0; }
|
||||
.settings-section details .field-help { margin-top: 10px; }
|
||||
.admin-user { content-visibility: auto; contain-intrinsic-size: auto 150px; border-bottom: 1px solid var(--line); padding: 17px 0; }
|
||||
.admin-user h4 { margin: 0 0 13px; font-size: 14px; font-weight: 550; }
|
||||
.admin-user-controls { display: grid; grid-template-columns: minmax(100px, 1fr) auto auto; gap: 14px; align-items: center; }
|
||||
.admin-user select { margin: 0; }
|
||||
.admin-user label { margin: 0; display: flex; align-items: center; gap: 6px; font-weight: 400; white-space: nowrap; }
|
||||
.pending-item { font-size: 13px; line-height: 1.8; padding: 12px 0; overflow-wrap: anywhere; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 600px) {
|
||||
.site-header { padding-top: 28px; }
|
||||
.site-nav { margin-top: 16px; }
|
||||
.construction { min-height: calc(100svh - 190px); padding: 40px 20px 75px; }
|
||||
.construction h1 { margin-bottom: 38px; }
|
||||
.construction-progress p { max-width: 320px; margin-inline: auto; line-height: 2.1; }
|
||||
.home-links { margin-top: 34px; gap: 22px; }
|
||||
.page-shell, .reading-shell, .account-shell { width: calc(100% - 40px); margin-top: 32px; }
|
||||
.page-heading h1 { font-size: 29px; }
|
||||
.article-list h2 { font-size: 20px; }
|
||||
.article-with-cover { gap: 17px; }
|
||||
.article-cover { flex-basis: 88px; }
|
||||
.article-cover img { width: 88px; height: 88px; }
|
||||
.article-description { font-size: 14px; }
|
||||
.prose { font-size: 15px; }
|
||||
.prose h2 { font-size: 23px; }
|
||||
.site-footer { padding-top: 30px; }
|
||||
pre { padding: 14px; font-size: 12px; }
|
||||
.admin-user-controls { grid-template-columns: 1fr auto; }
|
||||
.admin-user-controls .button { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } }
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderMarkdown } from '../src/lib/markdown';
|
||||
import type { Article } from '../src/lib/api';
|
||||
const article = (markdown: string): Article => ({
|
||||
meta: { title: 'Test', slug: 'alice~first', author: 'alice', description: '', tags: [], cover: null, created_at: '', date: '', updated: '', status: 'published' },
|
||||
markdown, format: 'markdown', entry: 'main.md',
|
||||
pages: [{ name: 'main.md', url: '' }, { name: 'chapter.md', url: '' }], assets: [{ name: 'cover.png', url: '' }],
|
||||
});
|
||||
test('rewrites only registered documents and assets, preserves anchors and creates heading IDs', () => {
|
||||
const html = renderMarkdown(article('# Intro\n[Next](./chapter.md#details)\n[Back](main.md)\n'), 'alice~first');
|
||||
assert.match(html, /<h2 id="intro">/);
|
||||
assert.match(html, /href="\/posts\/alice~first\/chapter.md#details"/);
|
||||
assert.match(html, /href="\/posts\/alice~first"/);
|
||||
assert.match(html, /src="\/api\/assets\/alice~first\/cover.png"/);
|
||||
assert.doesNotMatch(renderMarkdown(article(''), 'alice~first'), /<img/);
|
||||
});
|
||||
test('treats raw HTML as text and excludes active links, forged images and handlers', () => {
|
||||
for (const text of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '[x](javascript:alert%281%29)', '[x](data:text/html,evil)', '[x](//evil.example)', '']) {
|
||||
const html = renderMarkdown(article(text), 'alice~first');
|
||||
assert.doesNotMatch(html, /<script|<img[^>]+onerror|href="(?:javascript:|data:|\/\/)|src="https?:/i);
|
||||
}
|
||||
assert.match(renderMarkdown(article('```html\n<script>x</script>\n```'), 'alice~first'), /<script>/);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user