snapshot before merge

This commit is contained in:
zqlit committed 2026-06-01 17:57:22 +08:00
1 parent 06a5f3da4d
commit 671871e0b2
145 files changed
+9786 -59

No files matched your search

+396
View File
@@ -0,0 +1,396 @@
# Generate handlers.go based on the known API routes
handlers = r'''package main
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"write-server/internal/ai"
"write-server/internal/artalk"
"write-server/internal/config"
"write-server/internal/images"
"write-server/internal/posts"
"write-server/internal/recycle"
"write-server/internal/rssapi"
"write-server/internal/wechat"
)
const adminHTML = ` + "`" + `<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>WriteApp</title>
<style>:root{color-scheme:dark;--bg:#111827;--card:#1f2937;--text:#f3f4f6;--muted:#9ca3af;--accent:#60a5fa;--accent-hover:#93bbfd;--line:#374151;--success:#86efac}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px;max-width:420px;width:100%%}h1{font-size:18px;font-weight:700;margin-bottom:4px}.sub{font-size:13px;color:var(--muted);margin-bottom:20px}.row{display:flex;align-items:center;gap:10px;padding:12px 16px;background:rgba(255,255,255,0.03);border-radius:8px;margin-bottom:16px}.dot{width:10px;height:10px;border-radius:50%%;flex-shrink:0;background:var(--success)}.text{font-size:14px;font-weight:500}.ip{font-size:12px;color:var(--muted);margin-top:2px}.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all 0.15s;color:#fff;text-decoration:none;display:inline-block}.btn-primary{background:var(--accent)}.btn-primary:hover{background:var(--accent-hover)}.btn-secondary{background:transparent;border:1px solid var(--line);color:var(--text)}.btn-secondary:hover{background:rgba(255,255,255,0.05)}.link-row{margin-top:16px;padding:10px 14px;background:rgba(255,255,255,0.03);border-radius:8px;font-size:13px;color:var(--muted);word-break:break-all}.link-row a{color:var(--accent);text-decoration:none}.footer{margin-top:20px;font-size:11px;color:var(--muted);text-align:center}</style></head>
<body><div class="card"><h1>WriteApp</h1><p class="sub">Blog CMS + Telegram Bot &mdash; Go Edition</p>
<div class="row"><div class="dot"></div><div><div class="text">All Systems Running</div><div class="ip">Port 8016 &middot; LAN: %s</div></div></div>
<div class="actions"><a class="btn btn-primary" href="/">Open Editor</a><a class="btn btn-secondary" href="/api/stats" target="_blank">API Stats</a></div>
<div class="link-row">Next.js Frontend: <a href="/">/</a></div>
<div class="link-row">API: <a href="/api/posts">/api/posts</a> &middot; <a href="/api/stats">/api/stats</a></div>
<div class="footer">Single Process &middot; Go 1.26</div></div></body></html>` + "`" + `
func jsonOK(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonErr(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func readJSON(r *http.Request) (map[string]interface{}, error) {
body, _ := io.ReadAll(r.Body)
defer r.Body.Close()
var data map[string]interface{}
err := json.Unmarshal(body, &data)
return data, err
}
func gs(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok { return s }
}
return ""
}
func gb(m map[string]interface{}, key string) bool {
if v, ok := m[key]; ok {
if b, ok := v.(bool); ok { return b }
}
return false
}
func gsa(m map[string]interface{}, key string) []string {
if v, ok := m[key]; ok {
switch arr := v.(type) {
case []interface{}:
var result []string
for _, item := range arr {
if s, ok := item.(string); ok { result = append(result, s) }
}
return result
case []string:
return arr
}
}
return nil
}
func handlePosts(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
search := r.URL.Query().Get("search")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { page = 1 }
perPage, _ := strconv.Atoi(r.URL.Query().Get("perPage"))
if perPage < 1 { perPage = 20 }
includeDrafts := r.URL.Query().Get("includeDrafts") != "false"
recycle.CleanExpired()
list, total, totalPages := posts.ListPosts(search, page, perPage, includeDrafts)
totalCount := posts.GetPostCount()
jsonOK(w, map[string]interface{}{
"posts": list, "total": total, "totalPages": totalPages, "totalCount": totalCount,
})
case "POST":
body, _ := readJSON(r)
fmRaw, _ := body["frontMatter"].(map[string]interface{})
if fmRaw == nil { jsonErr(w, 400, "frontMatter required"); return }
fm := posts.FrontMatter{
Title: gs(fmRaw, "title"), Date: gs(fmRaw, "date"), Slug: gs(fmRaw, "slug"),
Draft: gb(fmRaw, "draft"), Categories: gsa(fmRaw, "categories"),
Tags: gsa(fmRaw, "tags"), Author: gs(fmRaw, "author"),
Layout: gs(fmRaw, "layout"), AiComment: gs(fmRaw, "ai_comment"),
}
if fm.Layout == "" { fm.Layout = "post" }
if fm.Title == "" || fm.Slug == "" { jsonErr(w, 400, "Title and slug required"); return }
dirPath, err := posts.CreatePost(fm, gs(body, "content"))
if err != nil { jsonErr(w, 500, err.Error()); return }
jsonOK(w, map[string]interface{}{"success": true, "dirPath": dirPath})
default:
jsonErr(w, 405, "method not allowed")
}
}
func handlePostBySlug(w http.ResponseWriter, r *http.Request) {
slug := extractSlug(r.URL.Path, "/api/posts/")
switch r.Method {
case "GET":
p := posts.GetPost(slug)
if p == nil { jsonErr(w, 404, "Post not found"); return }
jsonOK(w, p)
case "PUT":
body, _ := readJSON(r)
fmRaw, _ := body["frontMatter"].(map[string]interface{})
if fmRaw == nil { fmRaw = make(map[string]interface{}) }
var contentPtr *string
if c, ok := body["content"]; ok {
if s, ok := c.(string); ok { contentPtr = &s }
}
result, err := posts.UpdatePost(slug, fmRaw, contentPtr)
if err != nil { jsonErr(w, 404, err.Error()); return }
jsonOK(w, map[string]interface{}{"success": true, "dirPath": result.DirPath})
case "DELETE":
p := posts.GetPost(slug)
if p == nil { jsonErr(w, 404, "Post not found"); return }
if err := recycle.MoveToRecycle(slug, p.DirPath, p.FrontMatter.Title); err != nil {
jsonErr(w, 500, err.Error()); return
}
jsonOK(w, map[string]interface{}{"success": true})
default:
jsonErr(w, 405, "method not allowed")
}
}
func handlePostsBatch(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" { jsonErr(w, 405, "method not allowed"); return }
body, _ := readJSON(r)
slugsArr, _ := body["slugs"].([]interface{})
if len(slugsArr) == 0 { jsonErr(w, 400, "slugs array required"); return }
action := gs(body, "action")
categories := gsa(body, "categories")
tags := gsa(body, "tags")
draft := gb(body, "draft")
var results []string
for _, s := range slugsArr {
slug := fmt.Sprintf("%v", s)
if posts.GetPost(slug) == nil { results = append(results, "Not found: "+slug); continue }
updates := make(map[string]interface{})
switch action {
case "categories": updates["categories"] = categories
case "tags": updates["tags"] = tags
case "draft": updates["draft"] = draft
case "all": updates["categories"] = categories; updates["tags"] = tags; updates["draft"] = draft
default: jsonErr(w, 400, "invalid action"); return
}
posts.UpdatePost(slug, updates, nil)
results = append(results, "Updated: "+slug)
}
jsonOK(w, map[string]interface{}{"results": results})
}
func handleRecycle(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
jsonOK(w, map[string]interface{}{"items": recycle.ListRecycle()})
case "POST":
body, _ := readJSON(r)
slug := gs(body, "slug")
if slug == "" { jsonErr(w, 400, "slug required"); return }
if recycle.Restore(slug) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
case "DELETE":
body, _ := readJSON(r)
slug := gs(body, "slug")
if slug == "" { jsonErr(w, 400, "slug required"); return }
if recycle.PermanentDelete(slug) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
default:
jsonErr(w, 405, "method not allowed")
}
}
func handleAI(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
body, _ := readJSON(r)
prompt := gs(body, "prompt")
if prompt == "" { jsonErr(w, 400, "prompt required"); return }
resp, err := ai.Chat(prompt)
if err != nil { jsonErr(w, 500, err.Error()); return }
jsonOK(w, map[string]interface{}{"response": resp})
}
func handleImages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
jsonOK(w, map[string]interface{}{"images": images.ScanImages()})
case "DELETE":
body, _ := readJSON(r)
filename := gs(body, "filename")
if filename == "" { jsonErr(w, 400, "filename required"); return }
if images.DeleteImage(filename) { jsonOK(w, map[string]interface{}{"success": true}) } else { jsonErr(w, 404, "not found") }
default:
jsonErr(w, 405, "method not allowed")
}
}
func handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
r.ParseMultipartForm(32 << 20)
file, header, err := r.FormFile("file")
if err != nil { jsonErr(w, 400, "file required"); return }
defer file.Close()
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
destPath := filepath.Join(config.BLOG_ROOT, "static", "uploads", filename)
os.MkdirAll(filepath.Dir(destPath), 0755)
dst, err := os.Create(destPath)
if err != nil { jsonErr(w, 500, err.Error()); return }
defer dst.Close()
io.Copy(dst, file)
url := "/uploads/" + filename
jsonOK(w, map[string]interface{}{"url": url, "success": true})
}
func handleHugo(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
if hugoStarting { jsonOK(w, map[string]interface{}{"running": false, "starting": true}); return }
hugoStarting = true
defer func() { hugoStarting = false }()
exec.Command("taskkill", "/F", "/IM", "hugo.exe").Run()
cmd := exec.Command("hugo", "serve", "--port", "1313", "--noHTTPCache")
cmd.Dir = config.BLOG_ROOT
if err := cmd.Start(); err != nil { jsonErr(w, 500, err.Error()); return }
jsonOK(w, map[string]interface{}{"running": true, "url": "http://localhost:1313"})
}
func handleDeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
body, _ := readJSON(r)
title := gs(body, "title")
if title == "" { jsonErr(w, 400, "Title required"); return }
runGit := func(args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = config.BLOG_ROOT
out, err := cmd.CombinedOutput()
return string(out), err
}
var output string
if o, err := runGit("add", "content/post", "static"); err != nil { jsonErr(w, 500, string(o)); return } else { output += o }
if _, err := runGit("diff", "--cached", "--quiet"); err != nil {
if o, err2 := runGit("commit", "-m", "发布: "+title); err2 != nil { jsonErr(w, 500, string(o)); return } else { output += o }
}
o, _ := runGit("push", "origin", "main")
output += o
jsonOK(w, map[string]interface{}{"success": true, "output": output})
}
func handleStats(w http.ResponseWriter, r *http.Request) {
postCount := posts.GetPostCount()
linkCount := 0; feedCount := 0
if links, err := rssapi.GetLinks(true); err == nil { linkCount = len(links) }
if feeds, err := rssapi.GetFeeds(); err == nil { feedCount = len(feeds) }
jsonOK(w, map[string]interface{}{"posts": postCount, "feeds": feedCount, "links": linkCount})
}
func handleRSSLinks(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
all := r.URL.Query().Get("all") == "1"
links, err := rssapi.GetLinks(all)
if err != nil { jsonErr(w, 500, err.Error()); return }
jsonOK(w, map[string]interface{}{"links": links})
case "POST":
body, _ := readJSON(r)
url := gs(body, "url"); title := gs(body, "title"); desc := gs(body, "description")
if url == "" || title == "" { jsonErr(w, 400, "url and title required"); return }
rssapi.AddLink(url, title, desc)
jsonOK(w, map[string]interface{}{"success": true})
case "PUT":
body, _ := readJSON(r)
id := gs(body, "id")
enabled := gb(body, "enabled")
rssapi.ToggleLink(id, enabled)
jsonOK(w, map[string]interface{}{"success": true})
case "DELETE":
body, _ := readJSON(r)
id := gs(body, "id")
rssapi.DeleteLink(id)
jsonOK(w, map[string]interface{}{"success": true})
default:
jsonErr(w, 405, "method not allowed")
}
}
func handleRSSFeeds(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
feeds, err := rssapi.GetFeeds()
if err != nil { jsonErr(w, 500, err.Error()); return }
jsonOK(w, map[string]interface{}{"feeds": feeds})
case "POST":
body, _ := readJSON(r)
url := gs(body, "url"); title := gs(body, "title")
if url == "" || title == "" { jsonErr(w, 400, "url and title required"); return }
rssapi.AddFeed(url, title)
jsonOK(w, map[string]interface{}{"success": true})
case "DELETE":
body, _ := readJSON(r)
id := gs(body, "id")
rssapi.DeleteFeed(id)
jsonOK(w, map[string]interface{}{"success": true})
default:
jsonErr(w, 405, "method not allowed")
}
}
func handleWechatHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
articles, err := wechat.GetArticles()
if err != nil { jsonErr(w, 500, err.Error()); return }
jsonOK(w, map[string]interface{}{"articles": articles})
case "POST":
msg := r.URL.Query().Get("msg")
jsonOK(w, map[string]interface{}{"ok": true, "msg": msg})
default:
jsonErr(w, 405, "method not allowed")
}
}
func handleCommentsStats(w http.ResponseWriter, r *http.Request) {
stats := artalk.GetStats()
jsonOK(w, stats)
}
func handleCommentsCleanup(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
body, _ := readJSON(r)
slug := gs(body, "slug")
if slug == "" { jsonErr(w, 400, "slug required"); return }
result := artalk.CleanupComments(slug)
jsonOK(w, result)
}
func handleCommentsLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
body, _ := readJSON(r)
name := gs(body, "name"); email := gs(body, "email"); password := gs(body, "password")
if name == "" || email == "" || password == "" { jsonErr(w, 400, "name, email, password required"); return }
result := artalk.Login(name, email, password)
jsonOK(w, result)
}
func handleCommentsSub(w http.ResponseWriter, r *http.Request) {
slug := extractSlug(r.URL.Path, "/api/comments/")
comments := artalk.GetComments(slug)
jsonOK(w, comments)
}
func handleComments(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { jsonErr(w, 405, "method not allowed"); return }
body, _ := readJSON(r)
slug := gs(body, "slug"); content := gs(body, "content"); pageKey := gs(body, "page_key")
if slug == "" { jsonErr(w, 400, "slug required"); return }
result := artalk.PostComment(slug, content, pageKey)
jsonOK(w, result)
}
func extractSlug(path, prefix string) string {
p := strings.TrimPrefix(path, prefix)
p = strings.TrimSuffix(p, "/")
return p
}
'''
import "time" # needed below
with open(r"E:\GitHub\blog\write-go\cmd\writeserver\handlers.go", "w", encoding="utf-8") as f:
f.write(handlers)
print("handlers.go written, size:", len(handlers))