diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4f759c --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +SHELL := /bin/bash +GO ?= go +PNPM ?= pnpm +ADDR ?= :8080 + +.PHONY: help deps web server dev start build test clean db-reset stop logs + +help: + @echo "ONE · 一个博客" + @echo "" + @echo " make dev 前后端一起起(后端 8080,前端 3000 带热更新)" + @echo " make web 只构建前端到 frontend/dist" + @echo " make server 只起后端(8080)" + @echo " make start 构建前端 + 单端口启动(8080 同时服务前后端)" + @echo " make deps 安装前后端依赖" + @echo " make test 跑后端测试" + @echo " make stop 停掉 make dev 起的两个进程" + @echo " make logs 看 dev 进程日志" + @echo " make clean 清理构建产物" + @echo " make db-reset 删掉本地 SQLite 库(会清空数据)" + +deps: + cd backend && $(GO) mod download + cd frontend && $(PNPM) install + +web: + cd frontend && $(PNPM) run build + +server: + cd backend && ONE_ADDR=$(ADDR) $(GO) run . + +# 一条命令起后端 + 前端(开发用,两个进程) +# 输出写进 .run/*.log,避免后台进程占住终端导致命令不返回 +dev: + @mkdir -p .run + @(cd backend && { ONE_ADDR=$(ADDR) $(GO) run . > ../.run/server.log 2>&1 & echo $$! > ../.run/server.pid; }) + @(cd frontend && { $(PNPM) run dev > ../.run/web.log 2>&1 & echo $$! > ../.run/web.pid; }) + @echo "后端 http://localhost:8080 (日志 .run/server.log)" + @echo "前端 http://localhost:3000 (日志 .run/web.log)" + @echo "停止:make stop 看日志:make logs" + +# 生产式单端口:构建前端,由 Go 静态托管 +start: web + cd backend && ONE_ADDR=$(ADDR) $(GO) run . + +build: web + cd backend && $(GO) build -o ../one-server . + +test: + cd backend && $(GO) test ./... + +stop: + @for f in .run/server.pid .run/web.pid; do \ + if [ -f $$f ]; then pid=$$(cat $$f); \ + pkill -P $$pid 2>/dev/null; kill $$pid 2>/dev/null; \ + fi; \ + done + @rm -f .run/*.pid + @echo stopped + +logs: + @tail -f .run/server.log .run/web.log + +clean: + rm -rf frontend/dist one-server .run + +db-reset: + rm -f data/one.db data/one.db-wal data/one.db-shm diff --git a/README.md b/README.md new file mode 100644 index 0000000..829ab32 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# ONE · 一个博客 + +一个能跑通全流程的博客 MVP:后台登录 → 写文章(长文 / 短文)→ 发布 → 前台按「07 融合 + Twitter 信息流」风格展示。 + +- 后端:Go(`net/http` + `database/sql`,SQLite / PostgreSQL 双支持),在 `backend/` +- 前台 + 后台:Vue 3 + Vue Router + Vite,在 `frontend/`(pnpm 管理依赖) +- Markdown:服务端用 `goldmark` 渲染入库,编辑器实时预览用 `marked` + `DOMPurify` + +## 一条命令跑起来 + +```bash +make dev # 后端 8080 + 前端 3000(前端带热更新,已配好 /api 代理) +``` + +打开 http://localhost:3000 是前台,http://localhost:3000/admin 是后台。 +停掉:`make stop` + +想要单端口(前端构建产物由 Go 直接托管,接近生产形态): + +```bash +make start # 先 pnpm run build,再起 Go,只开 8080 +``` + +打开 http://localhost:8080 。 + +其它:`make web` 只构建前端、`make server` 只起后端、`make test` 跑后端测试、`make clean` 清构建产物、`make db-reset` 清空本地 SQLite 数据。 + +## 后台登录 + +默认账号密码来自环境变量,未设置时是 `admin` / `admin`(启动日志会打印出来): + +```bash +ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start +``` + +登录态是服务端签发的 httpOnly cookie(HMAC-SHA256,7 天有效),同时支持 `Authorization: Bearer `,方便用 curl / 脚本写文章。 + +## 环境变量 + +| 变量 | 默认 | 说明 | +| --- | --- | --- | +| `ONE_ADDR` | `:8080` | 监听地址 | +| `ONE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres` | +| `ONE_DB_DSN` | `./data/one.db` | 数据库连接串(Postgres 示例:`postgres://user:pass@localhost/one?sslmode=disable`) | +| `ONE_ADMIN_USER` | `admin` | 后台用户名 | +| `ONE_ADMIN_PASSWORD` | `admin` | 后台密码 | +| `ONE_SECRET` | 随机生成 | 会话签名密钥;不设的话重启后登录态失效 | +| `ONE_SITE_URL` | `http://localhost:8080` | RSS 里的站点地址,部署时务必改成真实域名 | +| `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 | +| `ONE_DATA_DIR` | `./data` | SQLite 数据目录 | + +## 目录结构 + +``` +backend/ Go 后端(模块名 oneblog) + main.go 路由装配、静态托管 SPA、优雅退出 + internal/config 环境变量 + internal/db SQLite / PostgreSQL 连接与 `?` → `$n` 占位符重写 + internal/model 数据结构 + internal/store schema 迁移 + 全部 SQL + internal/render goldmark 渲染、阅读时长估算、摘要截取 + internal/api 公开接口 + RSS(/api/*、/rss.xml) + internal/admin 后台接口与登录鉴权(/api/admin/*) + internal/httpx JSON 读写助手 +frontend/ Vue 3 前端(pnpm) + src/styles.css 设计 token 与正文排版(07 风格) + src/views 前台:时间线 / 详情 / 归档 / 标签 / 关于 + src/admin 后台:登录 / 列表 / 编辑器 / 标签 / 设置 + src/components 左栏导航、右栏卡片、时间线行 +``` + +原有那一版 Vue 前端(07 风格重写之前)保留在 git 历史的第一个 commit `edee708` 里, +需要对照或回滚:`git checkout edee708 -- frontend/src`。 + +## 接口 + +公开(无需登录): + +``` +GET /api/site 站点设置 +GET /api/posts?kind=&tag=&q=&page=&size= 已发布文章(kind: long|short) +GET /api/posts/:slug 文章详情 +GET /api/archive 按年 → 月分组 +GET /api/tags 标签与计数 +GET /rss.xml (/feed 同) RSS 2.0 +``` + +后台(需登录): + +``` +POST /api/admin/login {username,password} → {token} +POST /api/admin/logout +GET /api/admin/me +GET /api/admin/posts?status=&kind=&q=&page=&size= +POST /api/admin/posts 新建 +GET /api/admin/posts/:id +PUT /api/admin/posts/:id 更新(编辑器自动保存走这里) +DELETE /api/admin/posts/:id +GET /api/admin/tags +POST /api/admin/tags {name} +PUT /api/admin/tags/:id {name} +DELETE /api/admin/tags/:id +GET /api/admin/settings +PUT /api/admin/settings +``` + +`Post` 的关键字段:`kind`(`long` / `short`)、`title`、`slug`、`summary`、`content_md`、`content_html`、`status`、`published_at`、`reading_minutes`、`tags[]`。 +短文没有可见标题,但 `title` 仍会由正文首句生成,供归档与标签列表索引。 + +## 部署 + +生产形态是「一个二进制 + 一个静态目录」: + +```bash +make build # 产出 ./one-server,前端已打进 web/dist +ONE_ADDR=:8080 \ +ONE_SITE_URL=https://your.domain \ +ONE_DB_DRIVER=postgres \ +ONE_DB_DSN="postgres://user:pass@127.0.0.1/one?sslmode=disable" \ +ONE_SECRET=一串随机长字符串 \ +ONE_ADMIN_PASSWORD=强密码 \ +./one-server +``` + +要点: + +- **静态资源**:Go 直接托管 `web/dist`,未知路径回落到 `index.html`,所以 `/post/xxx`、`/admin` 刷新都不会 404。 +- **反向代理**:前面挂 Nginx / Caddy 时把 `/` 反代到 `ONE_ADDR` 即可;如果只在内网监听,可以不挂。 +- **SQLite 部署**:把 `data/` 挂到持久卷;并发写很低的博客足够用(已开 WAL + busy_timeout)。 +- **PostgreSQL 部署**:改 `ONE_DB_DRIVER` 与 `ONE_DB_DSN`,schema 会在启动时自动创建,无需手动迁移。 +- **systemd 示例**: + +```ini +[Unit] +Description=ONE blog +After=network.target + +[Service] +WorkingDirectory=/opt/one +Environment=ONE_ADDR=:8080 ONE_SITE_URL=https://your.domain ONE_SECRET=xxx ONE_ADMIN_PASSWORD=xxx +ExecStart=/opt/one/one-server +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +- **备份**:SQLite 直接拷 `data/one.db`(停写更稳);Postgres 用 `pg_dump`。 + +## 已验证 + +`make start` 后:后台登录 → 写长文与短文 → 发布 → 前台时间线(长文标题 + 摘要 / 短文正文铺开)、详情、归档、标签、RSS 全部可见; +编辑器改正文 1.5 秒后自动保存到服务端;桌面与移动宽度无横向滚动;SQLite 路径全流程跑通,PostgreSQL 的占位符重写有单元测试覆盖(本机没有 Postgres 实例,未做端到端验证)。 + +## 待确认 + +- 仓库地址:本仓库是在本地 `/Users/cjun/Code/one` 新建的 git 仓库,还没有 remote。给个地址就能推上去并绑进工作区。 +- 鉴权方式:目前是账号密码 + 签名 cookie。想改成纯 token 也可以,接口已经兼容 `Authorization: Bearer`。 +- 图片上传:MVP 未做,正文里先用图片外链。 diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..52a1540 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,22 @@ +module oneblog + +go 1.24 + +require ( + github.com/lib/pq v1.10.9 + github.com/yuin/goldmark v1.7.13 + modernc.org/sqlite v1.39.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..4a8cf15 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= +modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go new file mode 100644 index 0000000..f4b5beb --- /dev/null +++ b/backend/internal/admin/api.go @@ -0,0 +1,350 @@ +// Package admin serves the authenticated surface at /api/admin/*. +// It is mounted separately from the public API so the two never share a +// handler chain. +package admin + +import ( + "crypto/subtle" + "errors" + "net/http" + "strings" + + "oneblog/internal/config" + "oneblog/internal/httpx" + "oneblog/internal/model" + "oneblog/internal/store" +) + +type API struct { + Store *store.Store + Cfg *config.Config + Sessions *Sessions +} + +const cookieName = "one_session" + +func (a *API) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/admin/login", a.login) + mux.HandleFunc("/api/admin/logout", a.logout) + mux.HandleFunc("/api/admin/me", a.guard(a.me)) + + mux.HandleFunc("/api/admin/posts", a.guard(a.listPosts)) + mux.HandleFunc("/api/admin/posts/", a.guard(a.postByID)) + mux.HandleFunc("/api/admin/tags", a.guard(a.listTags)) + mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID)) + mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) + return mux +} + +// guard requires a valid session; the token may arrive as a cookie (browser) +// or as a Bearer token (CLI / API client). +func (a *API) guard(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := bearer(r) + if token == "" { + if c, err := r.Cookie(cookieName); err == nil { + token = c.Value + } + } + if token == "" || !a.valid(token) { + httpx.Unauthorized(w) + return + } + next(w, r) + } +} + +func bearer(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(strings.ToLower(h), "bearer ") { + return strings.TrimSpace(h[7:]) + } + return "" +} + +func (a *API) valid(token string) bool { + _, err := a.Sessions.Verify(token) + return err == nil +} + +// ---------- auth ---------- + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (a *API) login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httpx.Error(w, http.StatusMethodNotAllowed, "POST required") + return + } + var in loginRequest + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1 + passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1 + if !userOK || !passOK { + httpx.Unauthorized(w) + return + } + token, exp := a.Sessions.Issue(a.Cfg.AdminUser) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: exp, + MaxAge: a.Sessions.TTL(), + }) + httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339)}) +} + +const rfc3339 = "2006-01-02T15:04:05Z07:00" + +func (a *API) logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + httpx.OK(w, map[string]any{"ok": true}) +} + +func (a *API) me(w http.ResponseWriter, r *http.Request) { + httpx.OK(w, map[string]any{"user": a.Cfg.AdminUser}) +} + +// ---------- posts ---------- + +func (a *API) listPosts(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + o := store.ListOptions{ + Kind: httpx.QueryString(r, "kind"), + Tag: httpx.QueryString(r, "tag"), + Query: httpx.QueryString(r, "q"), + Status: httpx.QueryString(r, "status"), + Page: httpx.QueryInt(r, "page", 1), + Size: httpx.QueryInt(r, "size", 20), + } + if o.Status == "" { + o.Status = "any" + } + page, err := a.Store.List(o) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, page) + case http.MethodPost: + var in model.PostInput + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + in.Status = normalizeStatus(in.Status) + p, err := a.Store.Create(in) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.Created(w, p) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") + } +} + +func normalizeStatus(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case model.StatusDraft, model.StatusPublished: + return strings.ToLower(strings.TrimSpace(s)) + default: + return model.StatusDraft + } +} + +func (a *API) postByID(w http.ResponseWriter, r *http.Request) { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/posts/"), "/") + if rest == "" { + a.listPosts(w, r) + return + } + id, err := parseInt(rest) + if err != nil { + httpx.BadRequest(w, "bad post id") + return + } + switch r.Method { + case http.MethodGet: + p, err := a.Store.Get(id) + writeOne(w, p, err) + case http.MethodPut, http.MethodPatch: + var in model.PostInput + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + if in.Status != "" { + in.Status = normalizeStatus(in.Status) + } + p, err := a.Store.Update(id, in) + writeOne(w, p, err) + case http.MethodDelete: + if err := a.Store.Delete(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT/DELETE required") + } +} + +func writeOne(w http.ResponseWriter, p model.Post, err error) { + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + httpx.OK(w, p) +} + +// ---------- tags ---------- + +func (a *API) listTags(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + tags, err := a.Store.ListTags() + if err != nil { + httpx.ServerError(w, err) + return + } + if tags == nil { + tags = []model.Tag{} + } + httpx.OK(w, map[string]any{"tags": tags}) + case http.MethodPost: + var in struct { + Name string `json:"name"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + name := strings.TrimSpace(in.Name) + if name == "" { + httpx.BadRequest(w, "name required") + return + } + t, err := a.Store.CreateTag(name) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.Created(w, t) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") + } +} + +func (a *API) tagByID(w http.ResponseWriter, r *http.Request) { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/tags/"), "/") + id, err := parseInt(rest) + if err != nil { + httpx.BadRequest(w, "bad tag id") + return + } + switch r.Method { + case http.MethodPut, http.MethodPatch: + var in struct { + Name string `json:"name"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + t, err := a.Store.RenameTag(id, in.Name) + writeTag(w, t, err) + case http.MethodDelete: + if err := a.Store.DeleteTag(id); err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required") + } +} + +func writeTag(w http.ResponseWriter, t model.Tag, err error) { + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.Error(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OK(w, t) +} + +// ---------- settings ---------- + +func (a *API) settings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + st, err := a.Store.GetSettings() + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, st) + case http.MethodPut, http.MethodPost: + var in model.Settings + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + if err := a.Store.UpdateSettings(in); err != nil { + httpx.ServerError(w, err) + return + } + st, err := a.Store.GetSettings() + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, st) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required") + } +} + +func parseInt(s string) (int64, error) { + if s == "" { + return 0, errors.New("empty") + } + var n int64 + for _, c := range s { + if c < '0' || c > '9' { + return 0, errors.New("not a number") + } + n = n*10 + int64(c-'0') + } + return n, nil +} diff --git a/backend/internal/admin/session.go b/backend/internal/admin/session.go new file mode 100644 index 0000000..d0269d6 --- /dev/null +++ b/backend/internal/admin/session.go @@ -0,0 +1,71 @@ +package admin + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// Sessions are stateless: base64("user:expiryUnix") + "." + HMAC-SHA256. +// They survive restarts as long as ONE_SECRET stays the same. +type Sessions struct { + secret []byte + ttl time.Duration +} + +func NewSessions(secret string, ttl time.Duration) *Sessions { + if ttl <= 0 { + ttl = 7 * 24 * time.Hour + } + return &Sessions{secret: []byte(secret), ttl: ttl} +} + +var ErrBadSession = errors.New("invalid session") + +func (s *Sessions) Issue(user string) (string, time.Time) { + exp := time.Now().Add(s.ttl) + payload := base64.RawURLEncoding.EncodeToString([]byte(user + ":" + strconv.FormatInt(exp.Unix(), 10))) + return payload + "." + s.sign(payload), exp +} + +func (s *Sessions) Verify(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return "", ErrBadSession + } + if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) { + return "", ErrBadSession + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", ErrBadSession + } + i := strings.LastIndex(string(raw), ":") + if i <= 0 { + return "", ErrBadSession + } + user := string(raw)[:i] + expUnix, err := strconv.ParseInt(string(raw)[i+1:], 10, 64) + if err != nil { + return "", ErrBadSession + } + if time.Now().After(time.Unix(expUnix, 0)) { + return "", ErrBadSession + } + return user, nil +} + +func (s *Sessions) sign(payload string) string { + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (s *Sessions) TTL() int { return int(s.ttl.Seconds()) } + +func (s *Sessions) String() string { return fmt.Sprintf("sessions(ttl=%s)", s.ttl) } diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go new file mode 100644 index 0000000..fac733f --- /dev/null +++ b/backend/internal/api/api.go @@ -0,0 +1,252 @@ +// Package api serves the read-only public surface: site settings, posts, +// archive, tags and RSS. Nothing here requires authentication. +package api + +import ( + "encoding/xml" + "errors" + "html" + "net/http" + "strings" + "time" + + "oneblog/internal/config" + "oneblog/internal/httpx" + "oneblog/internal/model" + "oneblog/internal/store" +) + +type API struct { + Store *store.Store + Cfg *config.Config +} + +func (a *API) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { + httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver}) + }) + mux.HandleFunc("/api/site", a.site) + mux.HandleFunc("/api/posts", a.listPosts) + mux.HandleFunc("/api/posts/", a.getPost) + mux.HandleFunc("/api/archive", a.archive) + mux.HandleFunc("/api/tags", a.tags) + return mux +} + +// Mount registers the feed routes on the root mux, where they are not +// shadowed by the SPA catch-all. +func (a *API) Mount(root *http.ServeMux) { + root.Handle("/api/", a.Routes()) + root.HandleFunc("/rss.xml", a.RSS) + root.HandleFunc("/feed", a.RSS) + root.HandleFunc("/feed.xml", a.RSS) +} + +func (a *API) site(w http.ResponseWriter, r *http.Request) { + st, err := a.Store.GetSettings() + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, st) +} + +func listOptions(r *http.Request, defSize int) store.ListOptions { + return store.ListOptions{ + Kind: httpx.QueryString(r, "kind"), + Tag: httpx.QueryString(r, "tag"), + Query: httpx.QueryString(r, "q"), + Page: httpx.QueryInt(r, "page", 1), + Size: httpx.QueryInt(r, "size", defSize), + Status: model.StatusPublished, + } +} + +func (a *API) listPosts(w http.ResponseWriter, r *http.Request) { + page, err := a.Store.List(listOptions(r, 10)) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, page) +} + +func (a *API) getPost(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimPrefix(r.URL.Path, "/api/posts/") + slug = strings.TrimSuffix(slug, "/") + if slug == "" { + a.listPosts(w, r) + return + } + p, err := a.Store.GetBySlug(slug) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + if p.Status != model.StatusPublished { + httpx.NotFound(w) + return + } + httpx.OK(w, p) +} + +func (a *API) archive(w http.ResponseWriter, r *http.Request) { + years, err := a.Store.Archive() + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"years": years}) +} + +func (a *API) tags(w http.ResponseWriter, r *http.Request) { + tags, err := a.Store.ListTags() + if err != nil { + httpx.ServerError(w, err) + return + } + if tags == nil { + tags = []model.Tag{} + } + httpx.OK(w, map[string]any{"tags": tags}) +} + +// ---------- RSS ---------- + +type rssItem struct { + XMLName xml.Name `xml:"item"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + GUID string `xml:"guid"` + Category []string `xml:"category,omitempty"` +} + +type rssChannel struct { + XMLName xml.Name `xml:"channel"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language"` + LastBuild string `xml:"lastBuildDate"` + Items []rssItem `xml:"item"` +} + +type rssFeed struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel rssChannel `xml:"channel"` +} + +// RSS renders the site feed. +func (a *API) RSS(w http.ResponseWriter, r *http.Request) { + settings, err := a.Store.GetSettings() + if err != nil { + httpx.ServerError(w, err) + return + } + page, err := a.Store.List(store.ListOptions{Status: model.StatusPublished, Page: 1, Size: 30}) + if err != nil { + httpx.ServerError(w, err) + return + } + base := strings.TrimSuffix(a.Cfg.SiteURL, "/") + + feed := rssFeed{ + Version: "2.0", + Channel: rssChannel{ + Title: orDefault(settings.SiteTitle, "ONE"), + Link: base + "/", + Description: orDefault(settings.SiteDesc, ""), + Language: "zh-CN", + LastBuild: time.Now().UTC().Format(time.RFC1123Z), + Items: []rssItem{}, + }, + } + for _, p := range page.Items { + title := p.Title + if p.Kind == model.KindShort || title == "" { + title = shortTitle(p) + } + desc := p.Summary + if desc == "" { + desc = trimRunes(stripTags(p.ContentHTML), 160) + } + item := rssItem{ + Title: title, + Link: base + "/post/" + p.Slug, + Description: desc, + PubDate: rssDate(p.PublishedAt), + GUID: base + "/post/" + p.Slug, + Category: p.Tags, + } + if p.Kind == model.KindShort { + item.Title = "[短] " + title + } + feed.Channel.Items = append(feed.Channel.Items, item) + } + + out, err := xml.MarshalIndent(feed, "", " ") + if err != nil { + httpx.ServerError(w, err) + return + } + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(xml.Header)) + _, _ = w.Write(out) + _, _ = w.Write([]byte("\n")) +} + +func shortTitle(p model.Post) string { + s := strings.TrimSpace(stripTags(p.ContentHTML)) + if s == "" { + return "无题" + } + return trimRunes(s, 40) +} + +func stripTags(s string) string { + var b strings.Builder + in := false + for _, r := range s { + switch { + case r == '<': + in = true + case r == '>': + in = false + case !in: + b.WriteRune(r) + } + } + return html.UnescapeString(strings.TrimSpace(b.String())) +} + +func trimRunes(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + +func rssDate(rfc3339 string) string { + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return time.Now().UTC().Format(time.RFC1123Z) + } + return t.UTC().Format(time.RFC1123Z) +} + +func orDefault(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..625c101 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "strings" +) + +type Config struct { + Addr string + Driver string // sqlite | postgres + DSN string + AdminUser string + AdminPass string + SessionSec string + WebDist string + DataDir string + SiteURL string + InsecureDev bool +} + +func getenv(k, def string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return def +} + +func Load() (*Config, error) { + root := getenv("ONE_ROOT", "") + if root == "" { + if wd, err := os.Getwd(); err == nil { + root = filepath.Dir(wd) // server/ -> repo root + } else { + root = "." + } + } + + c := &Config{ + Addr: getenv("ONE_ADDR", ":8080"), + Driver: strings.ToLower(getenv("ONE_DB_DRIVER", "sqlite")), + AdminUser: getenv("ONE_ADMIN_USER", "admin"), + AdminPass: getenv("ONE_ADMIN_PASSWORD", "admin"), + SessionSec: getenv("ONE_SECRET", ""), + WebDist: getenv("ONE_WEB_DIST", filepath.Join(root, "frontend", "dist")), + DataDir: getenv("ONE_DATA_DIR", filepath.Join(root, "data")), + SiteURL: getenv("ONE_SITE_URL", "http://localhost:8080"), + } + + if c.Driver == "" { + c.Driver = "sqlite" + } + if c.Driver != "sqlite" && c.Driver != "postgres" && c.Driver != "postgresql" { + return nil, &badDriver{c.Driver} + } + if c.Driver == "postgresql" { + c.Driver = "postgres" + } + + if c.DSN = getenv("ONE_DB_DSN", ""); c.DSN == "" { + if c.Driver == "sqlite" { + c.DSN = filepath.Join(c.DataDir, "one.db") + } else { + c.DSN = "postgres://localhost/one?sslmode=disable" + } + } + + if c.SessionSec == "" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return nil, err + } + c.SessionSec = hex.EncodeToString(b) + } + + c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == "" + + return c, nil +} + +type badDriver struct{ d string } + +func (e *badDriver) Error() string { + return "unsupported ONE_DB_DRIVER: " + e.d + " (use sqlite or postgres)" +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..5cd5ba4 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,101 @@ +// Package db opens a *sql.DB for either SQLite or PostgreSQL and rewrites +// the shared `?` placeholders into PostgreSQL's `$n` form. +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + _ "github.com/lib/pq" + _ "modernc.org/sqlite" +) + +type Dialect int + +const ( + SQLite Dialect = iota + Postgres +) + +type DB struct { + *sql.DB + Dialect Dialect +} + +func Open(driver, dsn string) (*DB, error) { + var d Dialect + switch driver { + case "sqlite": + d = SQLite + if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil { + return nil, err + } + dsn = addSQLiteParams(dsn) + case "postgres": + d = Postgres + default: + return nil, fmt.Errorf("unsupported driver %q", driver) + } + + pool, err := sql.Open(driverName(driver), dsn) + if err != nil { + return nil, err + } + if d == SQLite { + // SQLite is single-writer; keep a small pool to avoid "database is locked". + pool.SetMaxOpenConns(1) + } else { + pool.SetMaxOpenConns(10) + } + if err := pool.Ping(); err != nil { + return nil, fmt.Errorf("connect %s: %w", driver, err) + } + return &DB{DB: pool, Dialect: d}, nil +} + +func driverName(driver string) string { + if driver == "postgres" { + return "postgres" + } + return "sqlite" +} + +func addSQLiteParams(dsn string) string { + if strings.HasPrefix(dsn, "file:") || strings.Contains(dsn, "?") { + return dsn + } + return dsn + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" +} + +// Rebind converts `?` placeholders to `$1..$n` on PostgreSQL. +func (d *DB) Rebind(q string) string { + if d.Dialect != Postgres { + return q + } + var b strings.Builder + b.Grow(len(q) + 8) + n := 0 + for _, r := range q { + if r == '?' { + n++ + b.WriteString("$") + b.WriteString(fmt.Sprint(n)) + continue + } + b.WriteRune(r) + } + return b.String() +} + +func (d *DB) Q(q string) string { return d.Rebind(q) } + +// AutoInc returns the column definition for an auto-incrementing primary key. +func (d *DB) AutoInc() string { + if d.Dialect == Postgres { + return "BIGSERIAL PRIMARY KEY" + } + return "INTEGER PRIMARY KEY AUTOINCREMENT" +} diff --git a/backend/internal/db/db_test.go b/backend/internal/db/db_test.go new file mode 100644 index 0000000..b4939dc --- /dev/null +++ b/backend/internal/db/db_test.go @@ -0,0 +1,28 @@ +package db + +import "testing" + +func TestRebind(t *testing.T) { + sqlite := &DB{Dialect: SQLite} + pg := &DB{Dialect: Postgres} + + q := "SELECT * FROM posts WHERE kind = ? AND status = ? LIMIT ? OFFSET ?" + + if got := sqlite.Rebind(q); got != q { + t.Errorf("sqlite must keep ? placeholders, got %q", got) + } + + want := "SELECT * FROM posts WHERE kind = $1 AND status = $2 LIMIT $3 OFFSET $4" + if got := pg.Rebind(q); got != want { + t.Errorf("postgres rebind:\n got %q\nwant %q", got, want) + } +} + +func TestAutoInc(t *testing.T) { + if got := (&DB{Dialect: SQLite}).AutoInc(); got != "INTEGER PRIMARY KEY AUTOINCREMENT" { + t.Errorf("sqlite autoincrement = %q", got) + } + if got := (&DB{Dialect: Postgres}).AutoInc(); got != "BIGSERIAL PRIMARY KEY" { + t.Errorf("postgres autoincrement = %q", got) + } +} diff --git a/backend/internal/httpx/httpx.go b/backend/internal/httpx/httpx.go new file mode 100644 index 0000000..923127e --- /dev/null +++ b/backend/internal/httpx/httpx.go @@ -0,0 +1,60 @@ +// Package httpx holds the tiny JSON helpers shared by the public and admin APIs. +package httpx + +import ( + "encoding/json" + "io" + "net/http" + "strconv" +) + +type ErrorResponse struct { + Error string `json:"error"` +} + +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func OK(w http.ResponseWriter, v any) { WriteJSON(w, http.StatusOK, v) } +func Created(w http.ResponseWriter, v any) { WriteJSON(w, http.StatusCreated, v) } + +func Error(w http.ResponseWriter, status int, msg string) { + WriteJSON(w, status, ErrorResponse{Error: msg}) +} + +func BadRequest(w http.ResponseWriter, msg string) { Error(w, http.StatusBadRequest, msg) } +func NotFound(w http.ResponseWriter) { Error(w, http.StatusNotFound, "not found") } +func Unauthorized(w http.ResponseWriter) { Error(w, http.StatusUnauthorized, "unauthorized") } +func ServerError(w http.ResponseWriter, err error) { + Error(w, http.StatusInternalServerError, err.Error()) +} + +func Decode(r *http.Request, dst any) error { + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + if err != nil { + return err + } + if len(body) == 0 { + return io.EOF + } + return json.Unmarshal(body, dst) +} + +func QueryInt(r *http.Request, key string, def int) int { + s := r.URL.Query().Get(key) + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return def + } + return n +} + +func QueryString(r *http.Request, key string) string { + return r.URL.Query().Get(key) +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go new file mode 100644 index 0000000..547d49e --- /dev/null +++ b/backend/internal/model/model.go @@ -0,0 +1,76 @@ +package model + +// Post kinds. "long" is a normal article; "short" is a Twitter-like note +// with no title shown in the timeline. +const ( + KindLong = "long" + KindShort = "short" +) + +const ( + StatusDraft = "draft" + StatusPublished = "published" +) + +type Post struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Slug string `json:"slug"` + Summary string `json:"summary"` + ContentMd string `json:"content_md,omitempty"` + ContentHTML string `json:"content_html"` + Status string `json:"status"` + PublishedAt string `json:"published_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ReadingMinutes int `json:"reading_minutes"` + Tags []string `json:"tags"` +} + +type PostInput struct { + Kind string `json:"kind"` + Title string `json:"title"` + Slug string `json:"slug"` + Summary string `json:"summary"` + ContentMd string `json:"content_md"` + Status string `json:"status"` + PublishedAt string `json:"published_at"` + Tags []string `json:"tags"` + ReadingMinutes *int `json:"reading_minutes"` +} + +type Tag struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Count int `json:"count"` +} + +type ArchiveMonth struct { + Month string `json:"month"` + Posts []Post `json:"posts"` +} + +type ArchiveYear struct { + Year string `json:"year"` + Months []ArchiveMonth `json:"months"` + Count int `json:"count"` +} + +type Page struct { + Items []Post `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +type Settings struct { + SiteTitle string `json:"site_title"` + SiteDesc string `json:"site_desc"` + AuthorName string `json:"author_name"` + AuthorBio string `json:"author_bio"` + FooterNote string `json:"footer_note"` + ICPLicense string `json:"icp"` + PostsPerPage int `json:"posts_per_page"` +} diff --git a/backend/internal/render/markdown.go b/backend/internal/render/markdown.go new file mode 100644 index 0000000..cd71008 --- /dev/null +++ b/backend/internal/render/markdown.go @@ -0,0 +1,117 @@ +// Package render turns Markdown into sanitized HTML for the public site. +package render + +import ( + "bytes" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +var md = goldmark.New( + goldmark.WithExtensions(extension.GFM, extension.Footnote), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithRendererOptions(html.WithHardWraps()), +) + +var unsafeScheme = regexp.MustCompile(`(?i)(href|src)\s*=\s*"(javascript|data|vbscript):[^"]*"`) + +// Markdown renders Markdown to HTML. Raw HTML stays escaped (goldmark default) +// and dangerous URL schemes are stripped. +func Markdown(src string) string { + var buf bytes.Buffer + if err := md.Convert([]byte(src), &buf); err != nil { + return "

" + escapeHTML(src) + "

" + } + out := buf.String() + out = unsafeScheme.ReplaceAllString(out, `$1="#"`) + return out +} + +func escapeHTML(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} + +// ReadingMinutes estimates reading time: ~400 CJK chars or ~220 latin words +// per minute, whichever dominates. +func ReadingMinutes(markdown string) int { + if strings.TrimSpace(markdown) == "" { + return 1 + } + cjk := 0 + latinWords := 0 + inWord := false + for _, r := range markdown { + switch { + case r >= 0x4E00 && r <= 0x9FFF, r >= 0x3400 && r <= 0x4DBF, + r >= 0x3000 && r <= 0x303F, r >= 0xFF00 && r <= 0xFFEF: + cjk++ + inWord = false + case unicode.IsSpace(r): + inWord = false + default: + if !inWord { + latinWords++ + inWord = true + } + } + } + minutes := cjk/400 + latinWords/220 + if minutes < 1 { + return 1 + } + return minutes +} + +// Excerpt builds a plain-text summary from Markdown when the author left the +// summary field empty. +func Excerpt(markdown string, limit int) string { + var b strings.Builder + inFence := false + for _, line := range strings.Split(markdown, "\n") { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "```") { + inFence = !inFence + continue + } + if inFence || t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, ">") { + continue + } + t = strings.TrimLeft(t, "-*+0123456789. ") + b.WriteString(t) + b.WriteString(" ") + } + s := strings.TrimSpace(b.String()) + if limit <= 0 { + limit = 140 + } + runes := []rune(s) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) + "…" +} + +// TitleFromMarkdown derives a fallback title for short posts. +func TitleFromMarkdown(markdown string) string { + for _, line := range strings.Split(markdown, "\n") { + t := strings.TrimSpace(strings.TrimLeft(line, "# ")) + if t != "" { + runes := []rune(t) + if len(runes) > 24 { + return string(runes[:24]) + "…" + } + return t + } + } + return "无题" +} + +func RuneLen(s string) int { return utf8.RuneCountInString(s) } diff --git a/backend/internal/render/markdown_test.go b/backend/internal/render/markdown_test.go new file mode 100644 index 0000000..f655e39 --- /dev/null +++ b/backend/internal/render/markdown_test.go @@ -0,0 +1,49 @@ +package render + +import ( + "strings" + "testing" +) + +func TestMarkdownRendersStructure(t *testing.T) { + out := Markdown("## 标题\n\n正文 **粗体**。\n\n- 一\n- 二\n\n> 引用\n") + for _, want := range []string{"粗体", "
  • 一
  • ", "
    "} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } +} + +func TestMarkdownEscapesRawHTML(t *testing.T) { + out := Markdown("") + if strings.Contains(out, " diff --git a/frontend/src/admin/AdminLayout.vue b/frontend/src/admin/AdminLayout.vue new file mode 100644 index 0000000..153032a --- /dev/null +++ b/frontend/src/admin/AdminLayout.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/admin/EditorView.vue b/frontend/src/admin/EditorView.vue new file mode 100644 index 0000000..e2b8b3a --- /dev/null +++ b/frontend/src/admin/EditorView.vue @@ -0,0 +1,605 @@ + + + + + diff --git a/frontend/src/admin/LoginView.vue b/frontend/src/admin/LoginView.vue new file mode 100644 index 0000000..15919c7 --- /dev/null +++ b/frontend/src/admin/LoginView.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/admin/PostsView.vue b/frontend/src/admin/PostsView.vue new file mode 100644 index 0000000..c2070b3 --- /dev/null +++ b/frontend/src/admin/PostsView.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/admin/SettingsView.vue b/frontend/src/admin/SettingsView.vue new file mode 100644 index 0000000..31d3671 --- /dev/null +++ b/frontend/src/admin/SettingsView.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/admin/TagsView.vue b/frontend/src/admin/TagsView.vue new file mode 100644 index 0000000..d69213e --- /dev/null +++ b/frontend/src/admin/TagsView.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/admin/auth.js b/frontend/src/admin/auth.js new file mode 100644 index 0000000..9ff25dc --- /dev/null +++ b/frontend/src/admin/auth.js @@ -0,0 +1,12 @@ +import { adminApi, session } from '../api' + +// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效 +export async function checkAuth() { + try { + await adminApi.me() + return true + } catch (e) { + session.clear() + return false + } +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..3af8401 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,73 @@ +const base = '' + +async function request(path, { method = 'GET', body, auth = false } = {}) { + const headers = { 'Content-Type': 'application/json' } + const res = await fetch(base + path, { + method, + headers, + credentials: 'include', + body: body === undefined ? undefined : JSON.stringify(body) + }) + if (res.status === 401) { + window.dispatchEvent(new CustomEvent('one:unauthorized')) + const err = new Error('未登录或登录已过期') + err.status = 401 + throw err + } + if (!res.ok) { + let msg = `请求失败(${res.status})` + try { + const data = await res.json() + if (data && data.error) msg = data.error + } catch (_) { + /* 非 JSON 响应 */ + } + const err = new Error(msg) + err.status = res.status + throw err + } + if (res.status === 204) return null + return res.json() +} + +export const publicApi = { + site: () => request('/api/site'), + posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)), + post: (slug) => request('/api/posts/' + encodeURIComponent(slug)), + archive: () => request('/api/archive'), + tags: () => request('/api/tags') +} + +export const adminApi = { + login: (username, password) => + request('/api/admin/login', { method: 'POST', body: { username, password } }), + logout: () => request('/api/admin/logout', { method: 'POST' }), + me: () => request('/api/admin/me'), + posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)), + post: (id) => request('/api/admin/posts/' + id), + createPost: (body) => request('/api/admin/posts', { method: 'POST', body }), + updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }), + deletePost: (id) => request('/api/admin/posts/' + id, { method: 'DELETE' }), + tags: () => request('/api/admin/tags'), + createTag: (name) => request('/api/admin/tags', { method: 'POST', body: { name } }), + renameTag: (id, name) => request('/api/admin/tags/' + id, { method: 'PUT', body: { name } }), + deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }), + settings: () => request('/api/admin/settings'), + saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }) +} + +// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名 +const USER_KEY = 'one.admin.user' + +export const session = { + get user() { + return localStorage.getItem(USER_KEY) || '' + }, + set user(v) { + if (v) localStorage.setItem(USER_KEY, v) + else localStorage.removeItem(USER_KEY) + }, + clear() { + localStorage.removeItem(USER_KEY) + } +} diff --git a/frontend/src/components/LeftNav.vue b/frontend/src/components/LeftNav.vue new file mode 100644 index 0000000..6f83f2c --- /dev/null +++ b/frontend/src/components/LeftNav.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/src/components/PostCard.vue b/frontend/src/components/PostCard.vue new file mode 100644 index 0000000..666f252 --- /dev/null +++ b/frontend/src/components/PostCard.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/frontend/src/components/RightRail.vue b/frontend/src/components/RightRail.vue new file mode 100644 index 0000000..8b255b7 --- /dev/null +++ b/frontend/src/components/RightRail.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue new file mode 100644 index 0000000..b75778d --- /dev/null +++ b/frontend/src/components/TopBar.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/components/common/Avatar.vue b/frontend/src/components/common/Avatar.vue deleted file mode 100644 index 1e3a4b1..0000000 --- a/frontend/src/components/common/Avatar.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/frontend/src/components/common/DarkModeSwitch.vue b/frontend/src/components/common/DarkModeSwitch.vue deleted file mode 100644 index 77d8087..0000000 --- a/frontend/src/components/common/DarkModeSwitch.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/frontend/src/components/common/EmptyState.vue b/frontend/src/components/common/EmptyState.vue deleted file mode 100644 index 3b9a261..0000000 --- a/frontend/src/components/common/EmptyState.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/components/common/FormattedNumber.vue b/frontend/src/components/common/FormattedNumber.vue deleted file mode 100644 index b264431..0000000 --- a/frontend/src/components/common/FormattedNumber.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/components/common/LanguageSwitch.vue b/frontend/src/components/common/LanguageSwitch.vue deleted file mode 100644 index f41e135..0000000 --- a/frontend/src/components/common/LanguageSwitch.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/src/components/common/Loading.vue b/frontend/src/components/common/Loading.vue deleted file mode 100644 index f199a70..0000000 --- a/frontend/src/components/common/Loading.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/frontend/src/components/common/Logo.vue b/frontend/src/components/common/Logo.vue deleted file mode 100644 index a62d0a1..0000000 --- a/frontend/src/components/common/Logo.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - - - diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue deleted file mode 100644 index 38f7498..0000000 --- a/frontend/src/components/common/MarkdownContent.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/frontend/src/components/common/PostCard.vue b/frontend/src/components/common/PostCard.vue deleted file mode 100644 index a4f31a7..0000000 --- a/frontend/src/components/common/PostCard.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - diff --git a/frontend/src/components/common/Skeleton.vue b/frontend/src/components/common/Skeleton.vue deleted file mode 100644 index b536973..0000000 --- a/frontend/src/components/common/Skeleton.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/frontend/src/components/common/Time.vue b/frontend/src/components/common/Time.vue deleted file mode 100644 index 49aff65..0000000 --- a/frontend/src/components/common/Time.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/DashboardMain.vue b/frontend/src/components/dashboard/DashboardMain.vue deleted file mode 100644 index 30f31a4..0000000 --- a/frontend/src/components/dashboard/DashboardMain.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/DashboardSidebar.vue b/frontend/src/components/dashboard/DashboardSidebar.vue deleted file mode 100644 index 80948f7..0000000 --- a/frontend/src/components/dashboard/DashboardSidebar.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/DashboardTopbar.vue b/frontend/src/components/dashboard/DashboardTopbar.vue deleted file mode 100644 index f2cded2..0000000 --- a/frontend/src/components/dashboard/DashboardTopbar.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/frontend/src/components/dashboard/PagesManager.vue b/frontend/src/components/dashboard/PagesManager.vue deleted file mode 100644 index 18f42a7..0000000 --- a/frontend/src/components/dashboard/PagesManager.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - diff --git a/frontend/src/components/home/HomeActivitiesTabs.vue b/frontend/src/components/home/HomeActivitiesTabs.vue deleted file mode 100644 index 0d3346e..0000000 --- a/frontend/src/components/home/HomeActivitiesTabs.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/src/components/home/HomeFeed.vue b/frontend/src/components/home/HomeFeed.vue deleted file mode 100644 index 252c433..0000000 --- a/frontend/src/components/home/HomeFeed.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/frontend/src/components/home/HomeSidebar.vue b/frontend/src/components/home/HomeSidebar.vue deleted file mode 100644 index 2c49461..0000000 --- a/frontend/src/components/home/HomeSidebar.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/frontend/src/components/home/HomeTabs.vue b/frontend/src/components/home/HomeTabs.vue deleted file mode 100644 index 2f17a2e..0000000 --- a/frontend/src/components/home/HomeTabs.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/frontend/src/components/home/PromotionLinks.vue b/frontend/src/components/home/PromotionLinks.vue deleted file mode 100644 index 3dda0ce..0000000 --- a/frontend/src/components/home/PromotionLinks.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - diff --git a/frontend/src/components/home/SearchInput.vue b/frontend/src/components/home/SearchInput.vue deleted file mode 100644 index 31d595c..0000000 --- a/frontend/src/components/home/SearchInput.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/src/components/site/PostFooter.vue b/frontend/src/components/site/PostFooter.vue deleted file mode 100644 index 5654164..0000000 --- a/frontend/src/components/site/PostFooter.vue +++ /dev/null @@ -1,137 +0,0 @@ - - -