Add tree multiselection, batch actions and Markdown export
This commit is contained in:
1 parent
f473ec64b6
commit
ffc95d6dbc
14 files changed
+532
-46
No files matched your search
+2
-1
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
|
||||
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
||||
|
||||
{"GET /api/v1/tree", authed, s.tree},
|
||||
{"POST /api/v1/export", authed, s.export},
|
||||
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
||||
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
|
||||
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
|
||||
@@ -182,7 +183,7 @@ func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler {
|
||||
writeError(w, 401, "API 令牌无效")
|
||||
return
|
||||
}
|
||||
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
|
||||
writeError(w, 403, "只读令牌不能修改数据")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) export(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
file, name, err := s.vault.Export(r.Context(), body.Paths)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer os.Remove(file.Name())
|
||||
contentType := "application/zip"
|
||||
if strings.HasSuffix(name, ".md") {
|
||||
contentType = "text/markdown; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
|
||||
http.ServeContent(w, r, name, time.Time{}, file)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportMarkdownAndReferencedAssets(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("资料/文章.md", "# 标题\n\n``\n[其他笔记](未选中.md)")
|
||||
if err := os.MkdirAll(filepath.Join(f.root, "assets"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(f.root, "assets/图.png"), []byte("图片内容"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["资料/文章.md"]}`, nil)
|
||||
data, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
t.Fatalf("导出失败:%d %s", response.StatusCode, data)
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
for _, file := range reader.File {
|
||||
r, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
files[file.Name] = string(content)
|
||||
}
|
||||
if files["assets/图.png"] != "图片内容" || !strings.Contains(files["资料/文章.md"], "../assets/图.png") || len(files) != 2 {
|
||||
t.Fatalf("附件或目录不符:%v", files)
|
||||
}
|
||||
f.write("单篇.md", "原始 Markdown")
|
||||
response = f.do(f.admin, "POST", "/api/v1/export", `{"paths":["单篇.md"]}`, nil)
|
||||
defer response.Body.Close()
|
||||
data, _ = io.ReadAll(response.Body)
|
||||
if response.StatusCode != 200 || string(data) != "原始 Markdown" || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/markdown") {
|
||||
t.Fatalf("单篇导出不符:%d %s", response.StatusCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRejectsMissingAssetsAndSymlinks(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("文章.md", "")
|
||||
for _, body := range []string{`{"paths":["文章.md"]}`, `{"paths":[".rawmark/app.db"]}`, `{"paths":[]}`} {
|
||||
response := f.do(f.admin, "POST", "/api/v1/export", body, nil)
|
||||
response.Body.Close()
|
||||
if response.StatusCode == 200 {
|
||||
t.Fatalf("不应成功:%s", body)
|
||||
}
|
||||
}
|
||||
f.write("真实.md", "内容")
|
||||
if err := os.Symlink(filepath.Join(f.root, "真实.md"), filepath.Join(f.root, "别名.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["别名.md"]}`, nil)
|
||||
response.Body.Close()
|
||||
if response.StatusCode != 400 {
|
||||
t.Fatalf("应拒绝符号链接:%d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "RawMark API",
|
||||
"version": "1",
|
||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌只能使用 GET。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -2326,6 +2326,61 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"post": {
|
||||
"summary": "导出原始 Markdown 和本地附件",
|
||||
"description": "接收非空路径列表;单篇无附件返回 .md,目录、多选或含附件返回 ZIP。ZIP 保留仓库相对路径;不下载外部资源,不跟随其他未选中笔记。缺失附件或非法路径返回错误。此 POST 接口不修改数据,只读令牌可调用。",
|
||||
"tags": [
|
||||
"文件"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"paths"
|
||||
],
|
||||
"properties": {
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "原始 Markdown 或 ZIP 附件",
|
||||
"content": {
|
||||
"application/zip": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"text/markdown": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "路径无效"
|
||||
},
|
||||
"404": {
|
||||
"description": "文件或附件不存在"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
Reference in new issue
Block a user