MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 "<p>" + escapeHTML(src) + "</p>"
|
||||
}
|
||||
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) }
|
||||
@@ -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{"<h2", "<strong>粗体</strong>", "<li>一</li>", "<blockquote>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownEscapesRawHTML(t *testing.T) {
|
||||
out := Markdown("<script>alert(1)</script>")
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Errorf("raw HTML should be escaped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownBlocksUnsafeSchemes(t *testing.T) {
|
||||
out := Markdown(`[x](javascript:alert(1))`)
|
||||
if strings.Contains(out, "javascript:") {
|
||||
t.Errorf("unsafe scheme should be stripped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadingMinutes(t *testing.T) {
|
||||
if got := ReadingMinutes(""); got != 1 {
|
||||
t.Errorf("empty content should be 1 minute, got %d", got)
|
||||
}
|
||||
long := strings.Repeat("字", 1200)
|
||||
if got := ReadingMinutes(long); got != 3 {
|
||||
t.Errorf("1200 CJK chars should be 3 minutes, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcerpt(t *testing.T) {
|
||||
got := Excerpt("# 标题\n\n这是正文。\n\n```go\nfmt.Println()\n```", 140)
|
||||
if strings.Contains(got, "fmt.Println") {
|
||||
t.Errorf("code fences should be dropped, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "这是正文。") {
|
||||
t.Errorf("excerpt should keep body text, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// Package store owns the schema and every SQL query. All SQL is written with
|
||||
// `?` placeholders and re-bound to `$n` when running on PostgreSQL.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/render"
|
||||
)
|
||||
|
||||
func renderHTML(md string) string { return render.Markdown(md) }
|
||||
|
||||
func readingMinutes(md string) int { return render.ReadingMinutes(md) }
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func New(d *db.DB) (*Store, error) {
|
||||
s := &Store{db: d}
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
ai := s.db.AutoInc()
|
||||
stmts := []string{
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS posts (
|
||||
id %s,
|
||||
kind TEXT NOT NULL DEFAULT 'long',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
slug TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
content_md TEXT NOT NULL DEFAULT '',
|
||||
content_html TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
reading_minutes INTEGER NOT NULL DEFAULT 1
|
||||
)`, ai),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags (
|
||||
id %s,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL
|
||||
)`, ai),
|
||||
`CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support.
|
||||
indexes := []struct{ name, ddl string }{
|
||||
{"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`},
|
||||
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
|
||||
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
|
||||
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
|
||||
}
|
||||
for _, ix := range indexes {
|
||||
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
return fmt.Errorf("migrate index %s: %w", ix.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.seedSettings()
|
||||
}
|
||||
|
||||
func (s *Store) seedSettings() error {
|
||||
defs := map[string]string{
|
||||
"site_title": "ONE · 一个博客",
|
||||
"site_desc": "长文与短文,同一种节奏。",
|
||||
"author_name": "ONE",
|
||||
"author_bio": "写点长的,也写点短的。",
|
||||
"footer_note": "© ONE · 一个博客",
|
||||
"icp": "",
|
||||
"posts_per_page": "10",
|
||||
}
|
||||
for k, v := range defs {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT (key) DO NOTHING`), k, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT OR IGNORE INTO settings(key,value) VALUES (?,?)`), k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
func (s *Store) GetSettings() (model.Settings, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`))
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
m := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return settingsFromMap(m), rows.Err()
|
||||
}
|
||||
|
||||
func settingsFromMap(m map[string]string) model.Settings {
|
||||
st := model.Settings{
|
||||
SiteTitle: m["site_title"],
|
||||
SiteDesc: m["site_desc"],
|
||||
AuthorName: m["author_name"],
|
||||
AuthorBio: m["author_bio"],
|
||||
FooterNote: m["footer_note"],
|
||||
ICPLicense: m["icp"],
|
||||
PostsPerPage: 10,
|
||||
}
|
||||
if n := atoi(m["posts_per_page"]); n > 0 {
|
||||
st.PostsPerPage = n
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func atoi(v string) int {
|
||||
n := 0
|
||||
for _, r := range v {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSettings(st model.Settings) error {
|
||||
if st.PostsPerPage <= 0 {
|
||||
st.PostsPerPage = 10
|
||||
}
|
||||
sets := map[string]string{
|
||||
"site_title": st.SiteTitle,
|
||||
"site_desc": st.SiteDesc,
|
||||
"author_name": st.AuthorName,
|
||||
"author_bio": st.AuthorBio,
|
||||
"footer_note": st.FooterNote,
|
||||
"icp": st.ICPLicense,
|
||||
"posts_per_page": fmt.Sprint(st.PostsPerPage),
|
||||
}
|
||||
for k, v := range sets {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
|
||||
type ListOptions struct {
|
||||
Kind string
|
||||
Tag string
|
||||
Query string
|
||||
Status string // "" = published only (public), "any" = all (admin)
|
||||
Page int
|
||||
Size int
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
const postCols = `id, kind, title, slug, summary, content_md, content_html, status,
|
||||
published_at, created_at, updated_at, reading_minutes`
|
||||
|
||||
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
|
||||
var p model.Post
|
||||
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd,
|
||||
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
|
||||
p.Tags = []string{}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) List(o ListOptions) (model.Page, error) {
|
||||
if o.Page < 1 {
|
||||
o.Page = 1
|
||||
}
|
||||
if o.Size < 1 || o.Size > 100 {
|
||||
o.Size = 10
|
||||
}
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
if o.Status == "any" {
|
||||
// admin: no status filter
|
||||
} else if o.Status != "" {
|
||||
where = append(where, "status = ?")
|
||||
args = append(args, o.Status)
|
||||
} else {
|
||||
where = append(where, "status = 'published'")
|
||||
}
|
||||
if o.Kind != "" {
|
||||
where = append(where, "kind = ?")
|
||||
args = append(args, o.Kind)
|
||||
}
|
||||
if o.Tag != "" {
|
||||
where = append(where, `id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE t.slug = ? OR t.name = ?)`)
|
||||
args = append(args, o.Tag, o.Tag)
|
||||
}
|
||||
if o.Query != "" {
|
||||
like := "%" + strings.ToLower(o.Query) + "%"
|
||||
where = append(where, `(lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(content_md) LIKE ?)`)
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
w := ""
|
||||
if len(where) > 0 {
|
||||
w = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
order := "published_at DESC"
|
||||
if o.OrderBy != "" {
|
||||
order = o.OrderBy
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
|
||||
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, postCols, w, order))
|
||||
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
|
||||
if err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []model.Post{}
|
||||
for rows.Next() {
|
||||
p, err := scanPost(rows)
|
||||
if err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
items = append(items, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
if err := s.attachTags(items); err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
return model.Page{Items: items, Total: total, Page: o.Page, Size: o.Size}, nil
|
||||
}
|
||||
|
||||
func (s *Store) attachTags(posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]any, 0, len(posts))
|
||||
idx := map[int64]int{}
|
||||
for i, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
idx[p.ID] = i
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
q := s.db.Q(fmt.Sprintf(`SELECT pt.post_id, t.name FROM post_tags pt
|
||||
JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id IN (%s) ORDER BY t.name`, ph))
|
||||
rows, err := s.db.Query(q, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pid int64
|
||||
var name string
|
||||
if err := rows.Scan(&pid, &name); err != nil {
|
||||
return err
|
||||
}
|
||||
if i, ok := idx[pid]; ok {
|
||||
posts[i].Tags = append(posts[i].Tags, name)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Get(id int64) (model.Post, error) {
|
||||
var p model.Post
|
||||
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE id = ?`), id)
|
||||
if err := scanPostInto(row, &p); err != nil {
|
||||
return p, err
|
||||
}
|
||||
items := []model.Post{p}
|
||||
if err := s.attachTags(items); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func (s *Store) GetBySlug(slug string) (model.Post, error) {
|
||||
var p model.Post
|
||||
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
|
||||
if err := scanPostInto(row, &p); err != nil {
|
||||
return p, err
|
||||
}
|
||||
items := []model.Post{p}
|
||||
if err := s.attachTags(items); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func scanPostInto(row *sql.Row, p *model.Post) error {
|
||||
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd,
|
||||
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Tags = []string{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- slug ----------
|
||||
|
||||
var slugSep = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
|
||||
func Slugify(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = slugSep.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Store) uniqueSlug(base string, excludeID int64) string {
|
||||
base = Slugify(base)
|
||||
if base == "" {
|
||||
base = "post"
|
||||
}
|
||||
candidate := base
|
||||
for i := 2; ; i++ {
|
||||
var id int64
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id FROM posts WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return candidate
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
|
||||
}
|
||||
candidate = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- write ----------
|
||||
|
||||
func (s *Store) Create(in model.PostInput) (model.Post, error) {
|
||||
p := model.Post{
|
||||
Kind: in.Kind,
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Slug: in.Slug,
|
||||
Status: in.Status,
|
||||
Tags: []string{},
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = model.KindLong
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = model.StatusDraft
|
||||
}
|
||||
if p.Slug == "" {
|
||||
p.Slug = Slugify(p.Title)
|
||||
}
|
||||
if p.Slug == "" {
|
||||
// Titles without any latin characters (typical for short notes) get a
|
||||
// date-based slug instead of colliding on "post", "post-2", ...
|
||||
p.Slug = "s-" + time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
p.Slug = s.uniqueSlug(p.Slug, 0)
|
||||
// Short posts have no visible title, but archive and tag listings still
|
||||
// need something to index them by.
|
||||
if p.Kind == model.KindShort && p.Title == "" {
|
||||
p.Title = render.TitleFromMarkdown(in.ContentMd)
|
||||
}
|
||||
p.Summary = strings.TrimSpace(in.Summary)
|
||||
p.ContentMd = in.ContentMd
|
||||
p.ContentHTML = renderHTML(in.ContentMd)
|
||||
p.PublishedAt = in.PublishedAt
|
||||
p.CreatedAt = now()
|
||||
p.UpdatedAt = p.CreatedAt
|
||||
if p.PublishedAt == "" {
|
||||
p.PublishedAt = p.CreatedAt
|
||||
}
|
||||
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
||||
p.ReadingMinutes = *in.ReadingMinutes
|
||||
} else {
|
||||
p.ReadingMinutes = readingMinutes(in.ContentMd)
|
||||
}
|
||||
|
||||
var id int64
|
||||
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,content_md,content_html,status,
|
||||
published_at,created_at,updated_at,reading_minutes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML,
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
} else {
|
||||
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML,
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
p.ID = id
|
||||
if err := s.setTags(id, in.Tags); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
|
||||
cur, err := s.Get(id)
|
||||
if err != nil {
|
||||
return cur, err
|
||||
}
|
||||
p := cur
|
||||
if in.Kind != "" {
|
||||
p.Kind = in.Kind
|
||||
}
|
||||
if in.Title != "" || in.Kind == model.KindShort {
|
||||
p.Title = strings.TrimSpace(in.Title)
|
||||
}
|
||||
if in.Summary != "" {
|
||||
p.Summary = strings.TrimSpace(in.Summary)
|
||||
}
|
||||
if in.ContentMd != "" {
|
||||
p.ContentMd = in.ContentMd
|
||||
p.ContentHTML = renderHTML(in.ContentMd)
|
||||
}
|
||||
if in.Status != "" {
|
||||
p.Status = in.Status
|
||||
}
|
||||
if in.Slug != "" && in.Slug != cur.Slug {
|
||||
p.Slug = s.uniqueSlug(in.Slug, id)
|
||||
}
|
||||
if in.PublishedAt != "" {
|
||||
p.PublishedAt = in.PublishedAt
|
||||
}
|
||||
p.UpdatedAt = now()
|
||||
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
||||
p.ReadingMinutes = *in.ReadingMinutes
|
||||
} else if in.ContentMd != "" {
|
||||
p.ReadingMinutes = readingMinutes(in.ContentMd)
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,content_md=?,
|
||||
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
|
||||
p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML, p.Status,
|
||||
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if in.Tags != nil {
|
||||
if err := s.setTags(id, in.Tags); err != nil {
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id int64) error {
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err == nil && n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- tags ----------
|
||||
|
||||
func (s *Store) setTags(postID int64, names []string) error {
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
tagID, err := s.upsertTag(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertTag(name string) (int64, error) {
|
||||
slug := Slugify(name)
|
||||
if slug == "" {
|
||||
slug = "tag"
|
||||
}
|
||||
var id int64
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err = s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) ListTags() ([]model.Tag, error) {
|
||||
q := s.db.Q(`SELECT t.id, t.name, t.slug, COUNT(pt.post_id) AS c
|
||||
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
|
||||
GROUP BY t.id, t.name, t.slug ORDER BY c DESC, t.name`)
|
||||
rows, err := s.db.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Tag{}
|
||||
for rows.Next() {
|
||||
var t model.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTag(name string) (model.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return model.Tag{}, errors.New("tag name required")
|
||||
}
|
||||
id, err := s.upsertTag(name)
|
||||
if err != nil {
|
||||
return model.Tag{}, err
|
||||
}
|
||||
return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
slug := Slugify(name)
|
||||
if name == "" {
|
||||
return model.Tag{}, errors.New("tag name required")
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil {
|
||||
return model.Tag{}, err
|
||||
}
|
||||
var t model.Tag
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id, name, slug, 0 FROM tags WHERE id = ?`), id).
|
||||
Scan(&t.ID, &t.Name, &t.Slug, &t.Count)
|
||||
if err == sql.ErrNoRows {
|
||||
return t, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTag(id int64) error {
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- archive ----------
|
||||
|
||||
func (s *Store) Archive() ([]model.ArchiveYear, error) {
|
||||
page, err := s.List(ListOptions{Status: model.StatusPublished, Page: 1, Size: 500})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
years := []model.ArchiveYear{}
|
||||
yearIdx := map[string]int{}
|
||||
monthIdx := map[string]int{}
|
||||
for _, p := range page.Items {
|
||||
y, m := splitDate(p.PublishedAt)
|
||||
if y == "" {
|
||||
continue
|
||||
}
|
||||
yi, ok := yearIdx[y]
|
||||
if !ok {
|
||||
years = append(years, model.ArchiveYear{Year: y, Months: []model.ArchiveMonth{}})
|
||||
yi = len(years) - 1
|
||||
yearIdx[y] = yi
|
||||
}
|
||||
key := y + "-" + m
|
||||
mi, ok := monthIdx[key]
|
||||
if !ok {
|
||||
years[yi].Months = append(years[yi].Months, model.ArchiveMonth{Month: m, Posts: []model.Post{}})
|
||||
mi = len(years[yi].Months) - 1
|
||||
monthIdx[key] = mi
|
||||
}
|
||||
years[yi].Count++
|
||||
years[yi].Months[mi].Posts = append(years[yi].Months[mi].Posts, p)
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
|
||||
func splitDate(rfc3339 string) (string, string) {
|
||||
if len(rfc3339) < 7 {
|
||||
return "", ""
|
||||
}
|
||||
return rfc3339[0:4], rfc3339[5:7]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Hello World": "hello-world",
|
||||
" Rebuild ONE ": "rebuild-one",
|
||||
"Go 语言 / 2026": "go-语言-2026",
|
||||
"!!!": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Slugify(in); got != want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOptionsRebind(t *testing.T) {
|
||||
// 列表查询的占位符数量必须和参数数量一致,否则在 PostgreSQL 上会直接报错
|
||||
d := &db.DB{Dialect: db.Postgres}
|
||||
q := d.Q(`SELECT id FROM posts WHERE kind = ? AND status = ? AND title LIKE ? LIMIT ? OFFSET ?`)
|
||||
if strings.Count(q, "$") != 5 {
|
||||
t.Errorf("expected 5 placeholders, got %q", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostInputDefaults(t *testing.T) {
|
||||
in := model.PostInput{Kind: model.KindShort, ContentMd: "一句话。"}
|
||||
if in.Kind != "short" {
|
||||
t.Errorf("kind = %q", in.Kind)
|
||||
}
|
||||
if in.Status != "" {
|
||||
t.Errorf("empty status means draft is applied by the API layer, got %q", in.Status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user