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"` Description string `json:"description"` 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 = "htmlkit-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: "config/pages.json", } store.load() os.MkdirAll("pages", 0755) os.MkdirAll("config", 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"` Description string `json:"description"` 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, Description: req.Description, 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"` Description string `json:"description"` 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.Description = req.Description 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}}
`