From 3c0b636d96f70f6f3c24ce93ee0fa7db0d25a42e Mon Sep 17 00:00:00 2001 From: XOF Date: Sun, 14 Dec 2025 21:29:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20main.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 607 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 main.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..f3843b7 --- /dev/null +++ b/main.go @@ -0,0 +1,607 @@ +package main + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +type Page struct { + ID string `json:"id"` + Name string `json:"name"` + Filename string `json:"filename"` + HTML string `json:"html"` + Tags []string `json:"tags"` + Views int `json:"views"` + Created time.Time `json:"created"` +} + +type Store struct { + Pages map[string]*Page `json:"pages"` + mu sync.RWMutex + filename string +} + +var ( + store *Store + sessions = make(map[string]time.Time) + sessMu sync.RWMutex + password string + port string +) + +func loadEnv() { + password = "admin" // default + port = "8080" // default + + file, err := os.Open(".env") + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key, val := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + switch key { + case "PASSWORD": + password = val + case "PORT": + port = val + } + } +} + +func main() { + loadEnv() + + store = &Store{ + Pages: make(map[string]*Page), + filename: "pages.json", + } + store.load() + + os.MkdirAll("pages", 0755) + + http.HandleFunc("/", handleIndex) + http.HandleFunc("/login", handleLogin) + http.HandleFunc("/logout", handleLogout) + http.HandleFunc("/admin", authMiddleware(handleAdmin)) + http.HandleFunc("/api/pages", authMiddleware(handleAPIPages)) + http.HandleFunc("/api/page/", handleAPIPage) + http.HandleFunc("/p/", handleViewPage) + + log.Printf("Server starting on :%s (password: %s)\n", port, password) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func (s *Store) load() { + data, err := os.ReadFile(s.filename) + if err != nil { + return + } + json.Unmarshal(data, s) +} + +func (s *Store) save() error { + s.mu.RLock() + defer s.mu.RUnlock() + data, _ := json.MarshalIndent(s, "", " ") + return os.WriteFile(s.filename, data, 0644) +} + +func generateID() string { + b := make([]byte, 8) + rand.Read(b) + return base64.URLEncoding.EncodeToString(b)[:11] +} + +func validFilename(s string) bool { + match, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, s) + return match && len(s) > 0 && len(s) < 100 +} + +func authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + sessMu.RLock() + exp, ok := sessions[cookie.Value] + sessMu.RUnlock() + if !ok || time.Now().After(exp) { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + store.mu.RLock() + pages := make([]*Page, 0, len(store.Pages)) + for _, p := range store.Pages { + pages = append(pages, p) + } + store.mu.RUnlock() + + sort.Slice(pages, func(i, j int) bool { + return pages[i].Created.After(pages[j].Created) + }) + + tmpl := template.Must(template.New("index").Parse(indexHTML)) + tmpl.Execute(w, pages) +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(loginHTML)) + return + } + + if r.FormValue("password") == password { + token := generateID() + sessMu.Lock() + sessions[token] = time.Now().Add(24 * time.Hour) + sessMu.Unlock() + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + MaxAge: 86400, + }) + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, _ := r.Cookie("session") + if cookie != nil { + sessMu.Lock() + delete(sessions, cookie.Value) + sessMu.Unlock() + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func handleAdmin(w http.ResponseWriter, r *http.Request) { + store.mu.RLock() + pages := make([]*Page, 0, len(store.Pages)) + for _, p := range store.Pages { + pages = append(pages, p) + } + store.mu.RUnlock() + + sort.Slice(pages, func(i, j int) bool { + return pages[i].Created.After(pages[j].Created) + }) + + tmpl := template.Must(template.New("admin").Parse(adminHTML)) + tmpl.Execute(w, pages) +} + +func handleAPIPages(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST": + createPage(w, r) + case "PUT": + updatePage(w, r) + case "DELETE": + deletePage(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func createPage(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Filename string `json:"filename"` + HTML string `json:"html"` + Tags []string `json:"tags"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if !validFilename(req.Filename) { + http.Error(w, "Invalid filename", http.StatusBadRequest) + return + } + + if len(req.HTML) > 1024*1024 { + http.Error(w, "HTML too large", http.StatusBadRequest) + return + } + + page := &Page{ + ID: generateID(), + Name: req.Name, + Filename: req.Filename, + HTML: req.HTML, + Tags: req.Tags, + Created: time.Now(), + } + + store.mu.Lock() + store.Pages[page.ID] = page + store.mu.Unlock() + + if err := os.WriteFile(filepath.Join("pages", req.Filename+".html"), []byte(req.HTML), 0644); err != nil { + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + + store.save() + json.NewEncoder(w).Encode(page) +} + +func updatePage(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Filename string `json:"filename"` + HTML string `json:"html"` + Tags []string `json:"tags"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + store.mu.Lock() + page, ok := store.Pages[req.ID] + if !ok { + store.mu.Unlock() + http.Error(w, "Page not found", http.StatusNotFound) + return + } + + oldFilename := page.Filename + page.Name = req.Name + page.Filename = req.Filename + page.HTML = req.HTML + page.Tags = req.Tags + store.mu.Unlock() + + if oldFilename != req.Filename { + os.Remove(filepath.Join("pages", oldFilename+".html")) + } + + os.WriteFile(filepath.Join("pages", req.Filename+".html"), []byte(req.HTML), 0644) + store.save() + json.NewEncoder(w).Encode(page) +} + +func deletePage(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + + store.mu.Lock() + page, ok := store.Pages[id] + if !ok { + store.mu.Unlock() + http.Error(w, "Page not found", http.StatusNotFound) + return + } + delete(store.Pages, id) + store.mu.Unlock() + + os.Remove(filepath.Join("pages", page.Filename+".html")) + store.save() + w.WriteHeader(http.StatusNoContent) +} + +func handleAPIPage(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/page/") + + store.mu.RLock() + page, ok := store.Pages[id] + store.mu.RUnlock() + + if !ok { + http.Error(w, "Page not found", http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(page) +} + +func handleViewPage(w http.ResponseWriter, r *http.Request) { + filename := strings.TrimPrefix(r.URL.Path, "/p/") + filename = strings.TrimSuffix(filename, ".html") + + store.mu.Lock() + var page *Page + for _, p := range store.Pages { + if p.Filename == filename { + p.Views++ + page = p + break + } + } + store.mu.Unlock() + + if page == nil { + http.NotFound(w, r) + return + } + + store.save() + + file, err := os.Open(filepath.Join("pages", filename+".html")) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src * data:; font-src *") + io.Copy(w, file) +} + +const indexHTML = ` + + + + +HTML Tools + + + +
+

HTML Tools

+
+ + + +Admin +
+
+
+{{range .}} +
+

{{.Name}}

+
{{.Created.Format "2006-01-02 15:04"}}
+{{if .Tags}}
{{range .Tags}}{{.}}{{end}}
{{end}} +
👁 {{.Views}} views
+
+{{end}} +
+ + +` + +const loginHTML = ` + + + +Login + + + +
+

Admin Login

+
+ + +
+
+ +` + +const adminHTML = ` + + + + +Admin + + + +
+

Admin Panel

+
+ +Home +Logout +
+
+
+
+

Create New Page

+ + + + + + + + +
+
+

Pages

+{{range .}} +
+
+{{.Name}}
+{{.Filename}}.html | {{.Created.Format "2006-01-02"}} | 👁 {{.Views}} +
+
+View + + +
+
+{{end}} +
+
+ + +`