Files
RawMark/internal/api/api.go
T

488 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api 提供 REST API(/api/v1)、事件推送与前端静态资源。前端只通过这套 API 访问数据。
package api
import (
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"mime"
"net"
"net/http"
"path"
"strings"
"sync"
"time"
"rawmark/internal/auth"
"rawmark/internal/events"
"rawmark/internal/search"
"rawmark/internal/store"
"rawmark/internal/vault"
)
const (
cookieName = "rawmark_session"
maxNoteBytes = 20 << 20
maxAssetBytes = 50 << 20
maxJSONBytes = 1 << 20
)
type Server struct {
vault *vault.Vault
auth *auth.Manager
store *store.Store
hub *events.Hub
index *search.Index
// 串行化设置的"读取-合并-写入"
settingsMu sync.Mutex
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
OnSetup func()
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
ThemesDir string
}
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
return &Server{vault: v, auth: a, store: st, hub: hub, index: index}
}
// access 为接口的访问要求
type access int
const (
public access = iota
// 登录会话或 API 令牌(只读令牌只能用 GET)
authed
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
sessionOnly
)
type route struct {
pattern string
access access
handler http.HandlerFunc
}
// routes 为全部接口;openapi.json 须与之一致(见 TestOpenAPICoversRoutes)
func (s *Server) routes() []route {
return []route{
{"GET /api/v1/health", public, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }},
{"GET /api/v1/openapi.json", public, serveOpenAPI},
{"GET /api/v1/auth/state", public, s.authState},
{"POST /api/v1/auth/setup", public, s.setup},
{"POST /api/v1/auth/login", public, s.login},
{"POST /api/v1/auth/logout", public, s.logout},
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
{"GET /api/v1/tree", authed, s.tree},
{"GET /api/v1/files/{path...}", authed, s.readFile},
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
{"POST /api/v1/move", authed, s.move},
{"POST /api/v1/folders", authed, s.mkdir},
{"POST /api/v1/assets", authed, s.uploadAsset},
{"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
{"GET /api/v1/events", authed, s.events},
{"GET /api/v1/search", authed, s.search},
{"GET /api/v1/settings", authed, s.getSettings},
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
{"GET /api/v1/themes", authed, s.listThemes},
{"GET /api/v1/themes/{id}", authed, s.getTheme},
{"GET /api/v1/shares", sessionOnly, s.listShares},
{"POST /api/v1/shares", sessionOnly, s.createShare},
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
{"GET /api/v1/tokens", sessionOnly, s.listTokens},
{"POST /api/v1/tokens", sessionOnly, s.createToken},
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
{"GET /api/v1/public/shares/{token}/appearance", public, s.publicAppearance},
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
}
}
func (s *Server) Handler(static fs.FS) http.Handler {
mux := http.NewServeMux()
for _, rt := range s.routes() {
var h http.Handler = rt.handler
switch rt.access {
case authed:
h = s.requireAuth(h, true)
case sessionOnly:
h = s.requireAuth(h, false)
}
mux.Handle(rt.pattern, h)
}
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
mux.Handle("/", newStaticHandler(static))
return securityHeaders(sameOrigin(mux))
}
// ---- 中间件 ----
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("Cache-Control", "no-store")
}
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
// sameOrigin 拒绝跨站的写请求(CSRF 防护的第二道防线,第一道是 SameSite=Strict Cookie)。
// 没有 Origin 头的请求来自非浏览器客户端,放行
func sameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if origin := r.Header.Get("Origin"); origin != "" {
if host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"); host != r.Host {
writeError(w, 403, "跨站请求被拒绝")
return
}
}
}
next.ServeHTTP(w, r)
})
}
// requireAuth 校验登录会话;allowToken 时也接受 Authorization: Bearer 的 API 令牌
func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
if !allowToken {
writeError(w, 403, "此接口只能在登录后使用,不接受 API 令牌")
return
}
scope, valid, err := s.auth.ValidateAPIToken(strings.TrimSpace(bearer))
if err != nil {
internalError(w, r, err)
return
}
if !valid {
writeError(w, 401, "API 令牌无效")
return
}
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, 403, "只读令牌不能修改数据")
return
}
next.ServeHTTP(w, r)
return
}
c, err := r.Cookie(cookieName)
if err != nil {
writeError(w, 401, "未登录")
return
}
ok, err := s.auth.Validate(c.Value)
if err != nil {
internalError(w, r, err)
return
}
if !ok {
writeError(w, 401, "登录已过期")
return
}
next.ServeHTTP(w, r)
})
}
// ---- 鉴权 ----
func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
setUp, err := s.auth.IsSetUp()
if err != nil {
internalError(w, r, err)
return
}
authed := false
if c, err := r.Cookie(cookieName); err == nil {
if authed, err = s.auth.Validate(c.Value); err != nil {
internalError(w, r, err)
return
}
}
writeJSON(w, 200, map[string]bool{"setUp": setUp, "authenticated": authed})
}
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
var body struct{ Code, Password string }
if !readJSON(w, r, &body) {
return
}
switch err := s.auth.Setup(body.Code, body.Password); {
case errors.Is(err, auth.ErrBadSetupCode), errors.Is(err, auth.ErrWeakPassword):
writeError(w, 400, err.Error())
case errors.Is(err, auth.ErrAlreadySetUp):
writeError(w, 409, err.Error())
case err != nil:
internalError(w, r, err)
default:
if s.OnSetup != nil {
s.OnSetup()
}
s.startSession(w, r, body.Password)
}
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var body struct{ Password string }
if !readJSON(w, r, &body) {
return
}
s.startSession(w, r, body.Password)
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) {
token, err := s.auth.Login(clientIP(r), password)
switch {
case errors.Is(err, auth.ErrRateLimited):
writeError(w, 429, err.Error())
case errors.Is(err, auth.ErrBadCredentials):
writeError(w, 401, err.Error())
case err != nil:
internalError(w, r, err)
default:
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
MaxAge: int(auth.SessionTTL / time.Second),
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteStrictMode,
})
writeJSON(w, 200, map[string]bool{"ok": true})
}
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
if err := s.auth.Logout(c.Value); err != nil {
internalError(w, r, err)
return
}
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
writeJSON(w, 200, map[string]bool{"ok": true})
}
// ---- 文件 ----
func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
entries, err := s.vault.Tree()
if err != nil {
internalError(w, r, err)
return
}
writeJSON(w, 200, map[string]any{"entries": entries})
}
const searchLimit = 50
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
results, err := s.index.Search(r.URL.Query().Get("q"), searchLimit)
if err != nil {
internalError(w, r, err)
return
}
writeJSON(w, 200, map[string]any{"results": results})
}
func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
data, rev, err := s.vault.Read(r.PathValue("path"))
if err != nil {
vaultError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("ETag", `"`+rev+`"`)
w.Header().Set("Cache-Control", "no-store")
w.Write(data)
}
// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖
func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
if err != nil {
writeError(w, 413, "笔记过大")
return
}
pre := vault.Precondition{
IfMatch: strings.Trim(r.Header.Get("If-Match"), `"`),
IfNoneMatch: r.Header.Get("If-None-Match") == "*",
}
rev, err := s.vault.Write(r.PathValue("path"), data, pre)
if err != nil {
vaultError(w, r, err)
return
}
w.Header().Set("ETag", `"`+rev+`"`)
writeJSON(w, 200, map[string]string{"rev": rev})
}
func (s *Server) deleteFile(w http.ResponseWriter, r *http.Request) {
item, err := s.vault.Delete(r.PathValue("path"))
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, item)
}
func (s *Server) move(w http.ResponseWriter, r *http.Request) {
var body struct{ From, To string }
if !readJSON(w, r, &body) {
return
}
if err := s.vault.Move(body.From, body.To); err != nil {
vaultError(w, r, err)
return
}
if err := s.store.MoveShares(body.From, body.To); err != nil {
internalError(w, r, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) mkdir(w http.ResponseWriter, r *http.Request) {
var body struct{ Path string }
if !readJSON(w, r, &body) {
return
}
if err := s.vault.Mkdir(body.Path); err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
// uploadAsset 接收 multipart 表单中的 file 字段,存入 ?note= 指定笔记的 .assets 目录
func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "缺少上传文件")
return
}
defer file.Close()
link, err := s.vault.SaveAsset(r.URL.Query().Get("note"), header.Filename, file, maxAssetBytes)
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]string{"link": link})
}
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
s.serveRaw(w, r, r.PathValue("path"))
}
func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) {
f, info, err := s.vault.OpenRaw(rel)
if err != nil {
vaultError(w, r, err)
return
}
defer f.Close()
h := w.Header()
h.Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'")
if ct := mime.TypeByExtension(path.Ext(info.Name())); ct != "" {
h.Set("Content-Type", ct)
}
h.Set("Cache-Control", "no-store")
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
// ---- 回收站 ----
func (s *Server) trashList(w http.ResponseWriter, r *http.Request) {
items, err := s.vault.TrashList()
if err != nil {
internalError(w, r, err)
return
}
writeJSON(w, 200, map[string]any{"items": items})
}
func (s *Server) trashRestore(w http.ResponseWriter, r *http.Request) {
item, err := s.vault.Restore(r.PathValue("id"))
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, item)
}
func (s *Server) trashPurge(w http.ResponseWriter, r *http.Request) {
if err := s.vault.Purge(r.PathValue("id")); err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
// ---- 响应工具 ----
// clientIP 返回限速用的来源地址(未经反向代理头部,避免伪造)
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil {
writeError(w, 400, "请求格式错误")
return false
}
return true
}
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 writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func internalError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("请求处理失败", "method", r.Method, "path", r.URL.Path, "err", err)
writeError(w, 500, "服务器内部错误")
}
func vaultError(w http.ResponseWriter, r *http.Request, err error) {
var conflict *vault.ConflictError
switch {
case errors.As(err, &conflict):
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
case errors.Is(err, vault.ErrInvalidPath):
writeError(w, 400, err.Error())
case errors.Is(err, vault.ErrNotFound):
writeError(w, 404, err.Error())
case errors.Is(err, vault.ErrExists):
writeError(w, 409, err.Error())
case errors.Is(err, vault.ErrTooLarge):
writeError(w, 413, err.Error())
default:
internalError(w, r, err)
}
}