Reference implementation for HTTP/Minima;
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}