diff --git a/admin-server/main.go b/admin-server/main.go index 1cb90b8..9aea8b6 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -531,6 +531,172 @@ func handleSections(w http.ResponseWriter, r *http.Request) { jsonResp(w, cfg.Sections) } +// ── 分组(section)增 / 改 / 删 ── +// +// 「历史访问」分组由 history-server 依据浏览记录重建,改名或删除都会被它 +// 覆写回来;这里明确拒绝,避免用户以为删掉了、下次同步又“长”回来。 + +const historySectionName = "历史访问" + +func isHistorySection(s Section) bool { + return strings.TrimSpace(s.Name) == historySectionName +} + +func handleAddSection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + var req struct { + Name string `json:"name"` + Icon string `json:"icon"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + jsonErr(w, 400, "分组名不能为空") + return + } + if name == historySectionName { + jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能手工创建") + return + } + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + for _, s := range cfg.Sections { + if strings.TrimSpace(s.Name) == name { + jsonErr(w, 409, "已存在同名分组") + return + } + } + icon := strings.TrimSpace(req.Icon) + if icon == "" { + icon = "fas fa-star" + } + cfg.Sections = append(cfg.Sections, Section{Name: name, Icon: icon}) + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg.Sections) +} + +func handleUpdateSection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + var req struct { + Index int `json:"index"` + Name string `json:"name"` + Icon string `json:"icon"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if req.Index < 0 || req.Index >= len(cfg.Sections) { + jsonErr(w, 400, "分组不存在") + return + } + if isHistorySection(cfg.Sections[req.Index]) { + jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能修改") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + jsonErr(w, 400, "分组名不能为空") + return + } + if name == historySectionName { + jsonErr(w, 409, "不能使用分组名「"+historySectionName+"」(它由浏览历史自动维护)") + return + } + for i, s := range cfg.Sections { + if i != req.Index && strings.TrimSpace(s.Name) == name { + jsonErr(w, 409, "已存在同名分组") + return + } + } + cfg.Sections[req.Index].Name = name + if icon := strings.TrimSpace(req.Icon); icon != "" { + cfg.Sections[req.Index].Icon = icon + } + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg.Sections) +} + +// handleDeleteSection 删除分组。默认拒绝删除非空分组(需 force=1), +// 避免误点一下就把一组服务连带删掉。 +func handleDeleteSection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + var req struct { + Index int `json:"index"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if req.Index < 0 || req.Index >= len(cfg.Sections) { + jsonErr(w, 400, "分组不存在") + return + } + if isHistorySection(cfg.Sections[req.Index]) { + jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能删除") + return + } + if n := len(cfg.Sections[req.Index].Items); n > 0 && r.URL.Query().Get("force") != "1" { + jsonErr(w, 409, fmt.Sprintf("该分组下还有 %d 个服务,删除会一并移除;确认请加 force=1", n)) + return + } + cfg.Sections = append(cfg.Sections[:req.Index], cfg.Sections[req.Index+1:]...) + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg.Sections) +} + func handleAddItem(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") @@ -845,6 +1011,17 @@ func main() { http.HandleFunc("/api/sections", cors(handleSections)) http.HandleFunc("/api/item", cors(handleAddItem)) http.HandleFunc("/api/item/delete", cors(handleDeleteItem)) + http.HandleFunc("/api/section", cors(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + handleAddSection(w, r) + case http.MethodDelete: + handleDeleteSection(w, r) + default: + jsonErr(w, 405, "method not allowed") + } + })) + http.HandleFunc("/api/section/update", cors(handleUpdateSection)) http.HandleFunc("/api/pageinfo", cors(handlePageInfo)) http.HandleFunc("/api/icon", cors(handleIconUpload)) http.HandleFunc("/api/wallpaper", cors(handleWallpaper)) diff --git a/admin-server/main_test.go b/admin-server/main_test.go index 8a2010a..2c4cb6e 100644 --- a/admin-server/main_test.go +++ b/admin-server/main_test.go @@ -3,6 +3,8 @@ package main import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" ) @@ -164,3 +166,79 @@ func TestFrameableVerdictWithoutCookieIsInconclusive(t *testing.T) { t.Fatalf("无 Cookie 应判为无法确认,got %+v", without) } } + +// 分组增删改:历史分组受保护;删非空分组必须显式 force。 +func TestSectionHandlers(t *testing.T) { + dir := t.TempDir() + oldConf := confFile + confFile = filepath.Join(dir, "conf.yml") + defer func() { confFile = oldConf }() + + orig := `appConfig: {} +pageInfo: {} +sections: + - name: 常用服务 + icon: fas fa-star + items: + - title: A + url: https://a.example + - name: 历史访问 + icon: fas fa-history + items: [] +` + if err := os.WriteFile(confFile, []byte(orig), 0o644); err != nil { + t.Fatal(err) + } + + call := func(h http.HandlerFunc, method, target, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + rec := httptest.NewRecorder() + h(rec, req) + return rec + } + + // 新增 + if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 200 { + t.Fatalf("新增分组应成功,got %d %s", rec.Code, rec.Body.String()) + } + // 重名 + if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 409 { + t.Fatalf("同名分组应 409,got %d", rec.Code) + } + // 不能手工创建历史分组 + if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"历史访问"}`); rec.Code != 409 { + t.Fatalf("手工创建历史分组应 409,got %d", rec.Code) + } + // 改名 + if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":2,"name":"改名后"}`); rec.Code != 200 { + t.Fatalf("改名应成功,got %d %s", rec.Code, rec.Body.String()) + } + // 历史分组不能改名 / 删除 + if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":1,"name":"X"}`); rec.Code != 409 { + t.Fatalf("改历史分组应 409,got %d", rec.Code) + } + if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":1}`); rec.Code != 409 { + t.Fatalf("删历史分组应 409,got %d", rec.Code) + } + // 非空分组不给 force 应 409 + if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":0}`); rec.Code != 409 { + t.Fatalf("删非空分组应 409,got %d", rec.Code) + } + // 给 force 才删 + if rec := call(handleDeleteSection, http.MethodDelete, "/api/section?force=1", `{"index":0}`); rec.Code != 200 { + t.Fatalf("force 删除应成功,got %d %s", rec.Code, rec.Body.String()) + } + + cfg, err := loadConfig() + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(cfg.Sections)) + for _, s := range cfg.Sections { + names = append(names, s.Name) + } + want := []string{"历史访问", "改名后"} + if len(names) != len(want) || names[0] != want[0] || names[1] != want[1] { + t.Fatalf("删除/改名后分组 = %v; want %v", names, want) + } +} diff --git a/admin/index.html b/admin/index.html index f7e0be6..8cbadfa 100644 --- a/admin/index.html +++ b/admin/index.html @@ -112,6 +112,24 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
+ ++ 分组就是导航页上的栏目,可以按需增删。初始可以一个都没有。 + 「历史访问」由浏览记录自动维护,不能改名或删除。 +
+这个分组还没有服务。
'} + `).join('') : '现在还没有分组。先在上面的「导航分组」里创建一个,再把服务加进去。
还没有任何分组。
'; + return; + } + box.innerHTML = S.map((s, i) => { + const locked = String(s.name || '').trim() === '历史访问'; + return ` +