Reference implementation for HTTP/Minima;
at main 20 kB view raw
1// HTTP/Minimal Reference Server 2// A compliant server implementation for the HTTP/Minimal specification. 3// 4// Usage: 5// go run main.go -dir ./content -port 8080 6// 7// The server will: 8// - Serve Markdown files from the content directory 9// - Content-negotiate between text/markdown and text/html 10// - Strip raw HTML from Markdown before serving 11// - Validate and enforce HTTP/Minimal constraints 12// - Serve /.well-known/http-minimal policy endpoint 13 14package main 15 16import ( 17 "bytes" 18 "encoding/json" 19 "flag" 20 "fmt" 21 "html/template" 22 "io" 23 "log" 24 "mime" 25 "net/http" 26 "os" 27 "path/filepath" 28 "regexp" 29 "strings" 30 31 "github.com/yuin/goldmark" 32 "github.com/yuin/goldmark/extension" 33 "github.com/yuin/goldmark/parser" 34 "github.com/yuin/goldmark/renderer/html" 35 "gopkg.in/yaml.v3" 36) 37 38// Config holds server configuration 39type Config struct { 40 Port string 41 ContentDir string 42 TemplateFile string 43 BaseURL string 44 Contact string 45} 46 47// FrontMatter represents YAML front matter in Markdown documents 48type FrontMatter struct { 49 Title string `yaml:"title"` 50 Author string `yaml:"author"` 51 Date string `yaml:"date"` 52 Lang string `yaml:"lang"` 53 License string `yaml:"license"` 54 Description string `yaml:"description"` 55} 56 57// WellKnown represents the /.well-known/http-minimal response 58type WellKnown struct { 59 HTTPMinimal string `json:"http_minimal"` 60 Compliant bool `json:"compliant"` 61 Scope string `json:"scope"` 62 Contact string `json:"contact,omitempty"` 63} 64 65// Server implements an HTTP/Minimal compliant server 66type Server struct { 67 config Config 68 markdown goldmark.Markdown 69 htmlTmpl *template.Template 70} 71 72// defaultHTMLTemplate is the built-in fallback template for rendering Markdown to browsers 73const defaultHTMLTemplate = `<!DOCTYPE html> 74<html lang="{{.Lang}}"> 75<head> 76 <meta charset="utf-8"> 77 <meta name="viewport" content="width=device-width, initial-scale=1"> 78 <title>{{.Title}}</title> 79 <!-- OpenGraph --> 80 <meta property="og:title" content="{{.Title}}"> 81 <meta property="og:type" content="article"> 82 {{if .URL}}<meta property="og:url" content="{{.URL}}">{{end}} 83 {{if .Description}}<meta property="og:description" content="{{.Description}}">{{end}} 84 <meta property="og:locale" content="{{.Lang}}"> 85 {{if .Author}}<meta name="author" content="{{.Author}}">{{end}} 86 <style> 87 :root { 88 --text: #1a1a1a; 89 --bg: #fefefe; 90 --link: #0066cc; 91 --code-bg: #f4f4f4; 92 } 93 @media (prefers-color-scheme: dark) { 94 :root { 95 --text: #e0e0e0; 96 --bg: #1a1a1a; 97 --link: #6db3f2; 98 --code-bg: #2d2d2d; 99 } 100 } 101 * { box-sizing: border-box; } 102 body { 103 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; 104 font-size: 18px; 105 line-height: 1.6; 106 color: var(--text); 107 background: var(--bg); 108 max-width: 65ch; 109 margin: 0 auto; 110 padding: 2rem 1rem; 111 } 112 h1, h2, h3, h4, h5, h6 { line-height: 1.2; margin-top: 1.5em; } 113 a { color: var(--link); } 114 pre, code { 115 font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; 116 font-size: 0.9em; 117 background: var(--code-bg); 118 } 119 pre { padding: 1rem; overflow-x: auto; } 120 code { padding: 0.1em 0.3em; border-radius: 3px; } 121 pre code { padding: 0; background: none; } 122 blockquote { 123 border-left: 3px solid var(--link); 124 margin-left: 0; 125 padding-left: 1rem; 126 font-style: italic; 127 } 128 img { max-width: 100%; height: auto; } 129 hr { border: none; border-top: 1px solid var(--text); opacity: 0.2; } 130 table { border-collapse: collapse; width: 100%; } 131 th, td { border: 1px solid var(--text); padding: 0.5rem; text-align: left; } 132 th { opacity: 0.8; } 133 </style> 134</head> 135<body> 136{{.Content}} 137</body> 138</html>` 139 140func main() { 141 config := Config{} 142 143 flag.StringVar(&config.Port, "port", "8080", "Listen port") 144 flag.StringVar(&config.ContentDir, "dir", "./content", "Content directory") 145 flag.StringVar(&config.TemplateFile, "template", "", "HTML template file (default: built-in template)") 146 flag.StringVar(&config.BaseURL, "base-url", "http://localhost:8080", "Base URL for the site") 147 flag.StringVar(&config.Contact, "contact", "", "Contact email for /.well-known/http-minimal") 148 flag.Parse() 149 150 server, err := NewServer(config) 151 if err != nil { 152 log.Fatalf("Failed to create server: %v", err) 153 } 154 155 log.Printf("HTTP/Minimal server starting on %s", config.Port) 156 log.Printf("Serving content from: %s", config.ContentDir) 157 log.Fatal(http.ListenAndServe(":"+config.Port, server)) 158} 159 160// TemplateViolation represents a compliance issue found in a template 161type TemplateViolation struct { 162 Rule string 163 Details string 164} 165 166// validateTemplate checks a template for HTTP/Minimal compliance violations 167func validateTemplate(content string) []TemplateViolation { 168 var violations []TemplateViolation 169 contentLower := strings.ToLower(content) 170 171 // Check for forbidden elements 172 forbiddenElements := []struct { 173 pattern string 174 rule string 175 }{ 176 {"<script", "No JavaScript: <script> tags are forbidden"}, 177 {"<iframe", "No embedded content: <iframe> tags are forbidden"}, 178 {"<form", "No data collection: <form> tags are forbidden"}, 179 {"<embed", "No embedded content: <embed> tags are forbidden"}, 180 {"<object", "No embedded content: <object> tags are forbidden"}, 181 {"<applet", "No embedded content: <applet> tags are forbidden"}, 182 } 183 184 for _, elem := range forbiddenElements { 185 if strings.Contains(contentLower, elem.pattern) { 186 violations = append(violations, TemplateViolation{ 187 Rule: elem.rule, 188 Details: fmt.Sprintf("Found '%s' in template", elem.pattern), 189 }) 190 } 191 } 192 193 // Check for inline JavaScript event handlers 194 eventHandlers := []string{ 195 "onclick", "onload", "onerror", "onmouseover", "onmouseout", 196 "onsubmit", "onfocus", "onblur", "onchange", "onkeydown", 197 "onkeyup", "onkeypress", "ondblclick", "onscroll", "onresize", 198 } 199 for _, handler := range eventHandlers { 200 pattern := regexp.MustCompile(`(?i)\s` + handler + `\s*=`) 201 if pattern.MatchString(content) { 202 violations = append(violations, TemplateViolation{ 203 Rule: "No JavaScript: inline event handlers are forbidden", 204 Details: fmt.Sprintf("Found '%s' attribute in template", handler), 205 }) 206 } 207 } 208 209 // Check for javascript: URLs 210 if strings.Contains(contentLower, "javascript:") { 211 violations = append(violations, TemplateViolation{ 212 Rule: "No JavaScript: javascript: URLs are forbidden", 213 Details: "Found 'javascript:' URL in template", 214 }) 215 } 216 217 // Check for common tracking/analytics patterns 218 trackingPatterns := []struct { 219 pattern string 220 name string 221 }{ 222 {"google-analytics.com", "Google Analytics"}, 223 {"googletagmanager.com", "Google Tag Manager"}, 224 {"facebook.net", "Facebook tracking"}, 225 {"plausible.io", "Plausible Analytics"}, 226 {"analytics.", "Analytics service"}, 227 {"tracking.", "Tracking service"}, 228 {"pixel.", "Tracking pixel"}, 229 {"beacon.", "Tracking beacon"}, 230 } 231 232 for _, tp := range trackingPatterns { 233 if strings.Contains(contentLower, tp.pattern) { 234 violations = append(violations, TemplateViolation{ 235 Rule: "No tracking: external tracking services are forbidden", 236 Details: fmt.Sprintf("Found reference to %s (%s)", tp.name, tp.pattern), 237 }) 238 } 239 } 240 241 // Check for external stylesheets (could be tracking vectors) 242 externalCSSPattern := regexp.MustCompile(`(?i)<link[^>]+rel\s*=\s*["']?stylesheet["']?[^>]+href\s*=\s*["']?https?://`) 243 if externalCSSPattern.MatchString(content) { 244 violations = append(violations, TemplateViolation{ 245 Rule: "External resources: external stylesheets may enable tracking", 246 Details: "Found external stylesheet link (warning)", 247 }) 248 } 249 250 // Check for external fonts (could be tracking vectors) 251 if strings.Contains(contentLower, "fonts.googleapis.com") || strings.Contains(contentLower, "fonts.gstatic.com") { 252 violations = append(violations, TemplateViolation{ 253 Rule: "External resources: external fonts may enable tracking", 254 Details: "Found Google Fonts reference (warning)", 255 }) 256 } 257 258 return violations 259} 260 261// NewServer creates a new HTTP/Minimal server 262func NewServer(config Config) (*Server, error) { 263 // Initialize Goldmark with GFM extensions 264 md := goldmark.New( 265 goldmark.WithExtensions( 266 extension.GFM, // Tables, strikethrough, autolinks 267 extension.Footnote, // Footnotes 268 ), 269 goldmark.WithParserOptions( 270 parser.WithAutoHeadingID(), 271 ), 272 goldmark.WithRendererOptions( 273 html.WithUnsafe(), // We'll strip HTML ourselves for validation 274 ), 275 ) 276 277 // Load HTML template 278 var tmpl *template.Template 279 var tmplContent string 280 if config.TemplateFile != "" { 281 // Load from file 282 content, err := os.ReadFile(config.TemplateFile) 283 if err != nil { 284 return nil, fmt.Errorf("failed to read template file: %w", err) 285 } 286 tmplContent = string(content) 287 tmpl, err = template.New("page").Parse(tmplContent) 288 if err != nil { 289 return nil, fmt.Errorf("failed to parse template file: %w", err) 290 } 291 log.Printf("Using custom template: %s", config.TemplateFile) 292 } else { 293 // Use built-in default 294 tmplContent = defaultHTMLTemplate 295 var err error 296 tmpl, err = template.New("page").Parse(tmplContent) 297 if err != nil { 298 return nil, fmt.Errorf("failed to parse default template: %w", err) 299 } 300 } 301 302 // Validate template for HTTP/Minimal compliance 303 violations := validateTemplate(tmplContent) 304 if len(violations) > 0 { 305 log.Printf("Template validation found %d issue(s):", len(violations)) 306 hasError := false 307 for _, v := range violations { 308 // Warnings don't block startup, errors do 309 isWarning := strings.Contains(v.Details, "(warning)") 310 if isWarning { 311 log.Printf(" WARNING: %s - %s", v.Rule, v.Details) 312 } else { 313 log.Printf(" ERROR: %s - %s", v.Rule, v.Details) 314 hasError = true 315 } 316 } 317 if hasError { 318 return nil, fmt.Errorf("template validation failed: %d compliance violation(s) found", len(violations)) 319 } 320 } 321 322 return &Server{ 323 config: config, 324 markdown: md, 325 htmlTmpl: tmpl, 326 }, nil 327} 328 329// ServeHTTP implements http.Handler 330func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 331 // Enforce method restrictions 332 if r.Method != http.MethodGet && r.Method != http.MethodHead { 333 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 334 return 335 } 336 337 // Strip forbidden request headers (log them for debugging) 338 if cookie := r.Header.Get("Cookie"); cookie != "" { 339 log.Printf("NOTICE: Stripped Cookie header from request to %s", r.URL.Path) 340 } 341 342 // Route handling 343 switch { 344 case r.URL.Path == "/.well-known/http-minimal": 345 s.handleWellKnown(w, r) 346 default: 347 s.handleContent(w, r) 348 } 349} 350 351// handleWellKnown serves the /.well-known/http-minimal endpoint 352func (s *Server) handleWellKnown(w http.ResponseWriter, r *http.Request) { 353 wellKnown := WellKnown{ 354 HTTPMinimal: "0.1", 355 Compliant: true, 356 Scope: "/", 357 Contact: s.config.Contact, 358 } 359 360 w.Header().Set("Content-Type", "application/json; charset=utf-8") 361 w.Header().Set("Cache-Control", "max-age=86400") 362 s.setMinimalHeaders(w) 363 364 json.NewEncoder(w).Encode(wellKnown) 365} 366 367// handleContent serves Markdown content with content negotiation 368func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) { 369 // Clean and resolve the path 370 urlPath := filepath.Clean(r.URL.Path) 371 if urlPath == "/" || urlPath == "." { 372 urlPath = "/index" 373 } 374 375 // Try to find the markdown file 376 mdPath := filepath.Join(s.config.ContentDir, urlPath+".md") 377 if _, err := os.Stat(mdPath); os.IsNotExist(err) { 378 // Try without .md extension (maybe it's a directory with index.md) 379 indexPath := filepath.Join(s.config.ContentDir, urlPath, "index.md") 380 if _, err := os.Stat(indexPath); err == nil { 381 mdPath = indexPath 382 } else { 383 // Check if it's a static file (images, etc.) 384 staticPath := filepath.Join(s.config.ContentDir, urlPath) 385 if info, err := os.Stat(staticPath); err == nil && !info.IsDir() { 386 s.serveStaticFile(w, r, staticPath) 387 return 388 } 389 http.NotFound(w, r) 390 return 391 } 392 } 393 394 // Read the markdown file 395 content, err := os.ReadFile(mdPath) 396 if err != nil { 397 log.Printf("Error reading file %s: %v", mdPath, err) 398 http.Error(w, "Internal server error", http.StatusInternalServerError) 399 return 400 } 401 402 // Parse front matter and content 403 frontMatter, body := s.parseFrontMatter(content) 404 405 // Strip raw HTML from markdown (HTTP/Minimal compliance) 406 cleanBody := s.stripRawHTML(body) 407 408 // Validate the document 409 if errors := s.validateDocument(cleanBody); len(errors) > 0 { 410 log.Printf("Validation warnings for %s: %v", mdPath, errors) 411 } 412 413 // Content negotiation 414 accept := r.Header.Get("Accept") 415 wantsMarkdown := s.prefersMarkdown(accept) 416 417 // Get file modification time for caching headers 418 info, _ := os.Stat(mdPath) 419 modTime := info.ModTime() 420 421 // Set common headers 422 s.setMinimalHeaders(w) 423 w.Header().Set("Last-Modified", modTime.UTC().Format(http.TimeFormat)) 424 w.Header().Set("Cache-Control", "max-age=3600") 425 w.Header().Set("Link", `</.well-known/http-minimal>; rel="profile"`) 426 427 if wantsMarkdown { 428 // Serve raw markdown 429 w.Header().Set("Content-Type", "text/markdown; charset=utf-8; variant=CommonMark") 430 w.Write(cleanBody) 431 } else { 432 // Render to HTML for browsers 433 s.renderHTML(w, r, frontMatter, cleanBody) 434 } 435} 436 437// serveStaticFile serves static files (images, etc.) 438func (s *Server) serveStaticFile(w http.ResponseWriter, r *http.Request, path string) { 439 // Validate the file type is allowed 440 ext := strings.ToLower(filepath.Ext(path)) 441 allowedTypes := map[string]string{ 442 ".jpg": "image/jpeg", 443 ".jpeg": "image/jpeg", 444 ".png": "image/png", 445 ".gif": "image/gif", 446 ".webp": "image/webp", 447 ".avif": "image/avif", 448 ".svg": "image/svg+xml", 449 ".ico": "image/x-icon", 450 } 451 452 contentType, allowed := allowedTypes[ext] 453 if !allowed { 454 http.Error(w, "Forbidden file type", http.StatusForbidden) 455 return 456 } 457 458 s.setMinimalHeaders(w) 459 w.Header().Set("Content-Type", contentType) 460 w.Header().Set("Cache-Control", "max-age=86400") 461 462 http.ServeFile(w, r, path) 463} 464 465// parseFrontMatter extracts YAML front matter from markdown content 466func (s *Server) parseFrontMatter(content []byte) (FrontMatter, []byte) { 467 fm := FrontMatter{ 468 Lang: "en", // Default language 469 } 470 471 if !bytes.HasPrefix(content, []byte("---\n")) { 472 return fm, content 473 } 474 475 // Find the closing --- 476 rest := content[4:] 477 end := bytes.Index(rest, []byte("\n---\n")) 478 if end == -1 { 479 return fm, content 480 } 481 482 // Parse YAML 483 yamlContent := rest[:end] 484 if err := yaml.Unmarshal(yamlContent, &fm); err != nil { 485 log.Printf("Warning: failed to parse front matter: %v", err) 486 return fm, content 487 } 488 489 // Return content after front matter 490 body := rest[end+5:] 491 return fm, body 492} 493 494// stripRawHTML removes raw HTML from Markdown content 495func (s *Server) stripRawHTML(content []byte) []byte { 496 // Pattern to match HTML tags 497 htmlBlockPattern := regexp.MustCompile(`(?s)<[a-zA-Z][^>]*>.*?</[a-zA-Z]+>|<[a-zA-Z][^>]*/?>`) 498 499 // Pattern to match HTML comments 500 commentPattern := regexp.MustCompile(`(?s)<!--.*?-->`) 501 502 result := commentPattern.ReplaceAll(content, []byte{}) 503 result = htmlBlockPattern.ReplaceAll(result, []byte{}) 504 505 return result 506} 507 508// validateDocument checks for HTTP/Minimal compliance 509func (s *Server) validateDocument(content []byte) []string { 510 var errors []string 511 512 // Check for remaining HTML (shouldn't exist after stripping, but double-check) 513 if bytes.Contains(content, []byte("<script")) { 514 errors = append(errors, "Document contains <script> tag") 515 } 516 if bytes.Contains(content, []byte("<iframe")) { 517 errors = append(errors, "Document contains <iframe> tag") 518 } 519 if bytes.Contains(content, []byte("<form")) { 520 errors = append(errors, "Document contains <form> tag") 521 } 522 523 // Check for images without alt text 524 imgPattern := regexp.MustCompile(`!\[\]\(`) 525 if imgPattern.Match(content) { 526 errors = append(errors, "Document contains images without alt text") 527 } 528 529 return errors 530} 531 532// prefersMarkdown checks if the client prefers markdown over HTML 533func (s *Server) prefersMarkdown(accept string) bool { 534 if accept == "" { 535 return false 536 } 537 538 // Parse Accept header 539 types := strings.Split(accept, ",") 540 for _, t := range types { 541 mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(t)) 542 if err != nil { 543 continue 544 } 545 546 switch mediaType { 547 case "text/markdown", "text/x-markdown": 548 return true 549 case "text/html", "application/xhtml+xml": 550 return false 551 case "*/*": 552 // Wildcard - prefer HTML for browsers 553 return false 554 } 555 } 556 557 return false 558} 559 560// renderHTML renders markdown to HTML using the template 561func (s *Server) renderHTML(w http.ResponseWriter, r *http.Request, fm FrontMatter, content []byte) { 562 var htmlBuf bytes.Buffer 563 if err := s.markdown.Convert(content, &htmlBuf); err != nil { 564 log.Printf("Error converting markdown: %v", err) 565 http.Error(w, "Internal server error", http.StatusInternalServerError) 566 return 567 } 568 569 // Set title from front matter or first heading 570 title := fm.Title 571 if title == "" { 572 title = s.extractTitle(content) 573 } 574 if title == "" { 575 title = "Untitled" 576 } 577 578 lang := fm.Lang 579 if lang == "" { 580 lang = "en" 581 } 582 583 // Build canonical URL 584 pageURL := s.config.BaseURL + r.URL.Path 585 586 // Extract description from front matter or first paragraph 587 description := fm.Description 588 if description == "" { 589 description = s.extractDescription(content) 590 } 591 592 data := struct { 593 Title string 594 Lang string 595 Content template.HTML 596 URL string 597 Description string 598 Author string 599 }{ 600 Title: title, 601 Lang: lang, 602 Content: template.HTML(htmlBuf.String()), 603 URL: pageURL, 604 Description: description, 605 Author: fm.Author, 606 } 607 608 w.Header().Set("Content-Type", "text/html; charset=utf-8") 609 610 var pageBuf bytes.Buffer 611 if err := s.htmlTmpl.Execute(&pageBuf, data); err != nil { 612 log.Printf("Error executing template: %v", err) 613 http.Error(w, "Internal server error", http.StatusInternalServerError) 614 return 615 } 616 617 io.Copy(w, &pageBuf) 618} 619 620// extractDescription extracts the first paragraph from markdown content 621func (s *Server) extractDescription(content []byte) string { 622 lines := bytes.Split(content, []byte("\n")) 623 var paragraph []byte 624 inParagraph := false 625 626 for _, line := range lines { 627 trimmed := bytes.TrimSpace(line) 628 629 // Skip headings, blank lines at start, and front matter markers 630 if len(trimmed) == 0 { 631 if inParagraph { 632 break // End of first paragraph 633 } 634 continue 635 } 636 if bytes.HasPrefix(trimmed, []byte("#")) { 637 continue 638 } 639 if bytes.HasPrefix(trimmed, []byte("---")) { 640 continue 641 } 642 if bytes.HasPrefix(trimmed, []byte("-")) || bytes.HasPrefix(trimmed, []byte("*")) { 643 if !inParagraph { 644 continue // Skip list items at start 645 } 646 break 647 } 648 649 // Found paragraph text 650 inParagraph = true 651 if len(paragraph) > 0 { 652 paragraph = append(paragraph, ' ') 653 } 654 paragraph = append(paragraph, trimmed...) 655 } 656 657 desc := string(paragraph) 658 // Truncate to reasonable length for og:description 659 if len(desc) > 200 { 660 desc = desc[:197] + "..." 661 } 662 return desc 663} 664 665// extractTitle extracts the first heading from markdown content 666func (s *Server) extractTitle(content []byte) string { 667 lines := bytes.Split(content, []byte("\n")) 668 for _, line := range lines { 669 line = bytes.TrimSpace(line) 670 if bytes.HasPrefix(line, []byte("# ")) { 671 return string(bytes.TrimPrefix(line, []byte("# "))) 672 } 673 } 674 return "" 675} 676 677// setMinimalHeaders sets headers required by HTTP/Minimal and removes forbidden ones 678func (s *Server) setMinimalHeaders(w http.ResponseWriter) { 679 // Explicitly delete any forbidden headers that might be set by middleware 680 w.Header().Del("Set-Cookie") 681 w.Header().Del("WWW-Authenticate") 682 w.Header().Del("Content-Security-Policy") 683 w.Header().Del("X-Frame-Options") 684 w.Header().Del("Refresh") 685 686 // Add security headers that don't conflict with the spec 687 w.Header().Set("X-Content-Type-Options", "nosniff") 688}