补齐后端最小功能和前端

This commit is contained in:
ChuNa7a committed 2026-10-16 23:36:56 +08:00
1 parent 0e347aad9e
commit 3e7c9420fe
60 files changed
+8181 -329

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.astro
.env
.env.*
tests
+4
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
# 前端约定
首页只承担施工说明与导航,标题、进度文字、入口和页脚全部居中。大标题使用用户指定的“个人博客正在施工中”,只列真实已完成和仍未完成的功能。
颜色为白色 `#ffffff`、正文 `#202a2f`、次要文字 `#64727a`、分隔线 `#dce3e7`、浅底 `#f5f7f8` 和强调色 `#315e75`。使用系统中文无衬线字体,不从外部字体服务加载资源。标题靠字号和留白区分层次,不使用渐变、大幅插画或装饰标签。
阅读页和表单按阅读习惯左对齐。文章列表使用自然分行,标题及元信息比封面优先。桌面阅读宽度最多 720px,移动端至少保留 20px 边距。页面提供键盘焦点、显式标签、错误信息和真实空状态。
设计复核时移除了初始方案中的暖色纸张和陶土强调色,改用中性白底及克制的蓝灰,避免套用通用博客模板。唯一醒目的元素是施工页的大标题。
+20
View File
@@ -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"]
+16
View File
@@ -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' },
},
},
});
+5772
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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"
}
}
+1
View File
@@ -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

+32
View File
@@ -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>
+47
View File
@@ -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);
}
+60
View File
@@ -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 }),
},
});
}
+10
View File
@@ -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;
});
+4
View File
@@ -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>
+31
View File
@@ -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>
+45
View File
@@ -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>
+22
View File
@@ -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>
+24
View File
@@ -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>
+30
View File
@@ -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>
+154
View File
@@ -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();
+162
View File
@@ -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; } }
+24
View File
@@ -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![Cover](cover.png)'), '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('![bad](https://evil.example/a.png)'), '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)', '![bad](../secret.png)']) {
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'), /&lt;script&gt;/);
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]
}