Reference implementation for HTTP/Minima;
fork

Configure Feed

Select the types of activity you want to include in your feed.

Inital commit

Steve Layton 7707e3e9

+1666
+16
.gitignore
··· 1 + # Binaries 2 + httpmini-refserver 3 + *.exe 4 + 5 + # IDE 6 + .idea/ 7 + .vscode/ 8 + *.swp 9 + *.swo 10 + 11 + # OS 12 + .DS_Store 13 + Thumbs.db 14 + 15 + # Local dev 16 + *.local
+116
LICENSE
··· 1 + CC0 1.0 Universal 2 + 3 + Statement of Purpose 4 + 5 + The laws of most jurisdictions throughout the world automatically confer 6 + exclusive Copyright and Related Rights (defined below) upon the creator and 7 + subsequent owner(s) (each and all, an "owner") of an original work of 8 + authorship and/or a database (each, a "Work"). 9 + 10 + Certain owners wish to permanently relinquish those rights to a Work for the 11 + purpose of contributing to a commons of creative, cultural and scientific 12 + works ("Commons") that the public can reliably and without fear of later 13 + claims of infringement build upon, modify, incorporate in other works, reuse 14 + and redistribute as freely as possible in any form whatsoever and for any 15 + purposes, including without limitation commercial purposes. These owners may 16 + contribute to the Commons to promote the ideal of a free culture and the 17 + further production of creative, cultural and scientific works, or to gain 18 + reputation or greater distribution for their Work in part through the use and 19 + efforts of others. 20 + 21 + For these and/or other purposes and motivations, and without any expectation 22 + of additional consideration or compensation, the person associating CC0 with a 23 + Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 + and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 + and publicly distribute the Work under its terms, with knowledge of his or her 26 + Copyright and Related Rights in the Work and the meaning and intended legal 27 + effect of CC0 on those rights. 28 + 29 + 1. Copyright and Related Rights. A Work made available under CC0 may be 30 + protected by copyright and related or neighboring rights ("Copyright and 31 + Related Rights"). Copyright and Related Rights include, but are not limited 32 + to, the following: 33 + 34 + i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 + and translate a Work; 36 + 37 + ii. moral rights retained by the original author(s) and/or performer(s); 38 + 39 + iii. publicity and privacy rights pertaining to a person's image or likeness 40 + depicted in a Work; 41 + 42 + iv. rights protecting against unfair competition in regards to a Work, 43 + subject to the limitations in paragraph 4(a), below; 44 + 45 + v. rights protecting the extraction, dissemination, use and reuse of data in 46 + a Work; 47 + 48 + vi. database rights (such as those arising under Directive 96/9/EC of the 49 + European Parliament and of the Council of 11 March 1996 on the legal 50 + protection of databases, and under any national implementation thereof, 51 + including any amended or successor version of such directive); and 52 + 53 + vii. other similar, equivalent or corresponding rights throughout the world 54 + based on applicable law or treaty, and any national implementations thereof. 55 + 56 + 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 + applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 + unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 + and Related Rights and associated claims and causes of action, whether now 60 + known or unknown (including existing as well as future claims and causes of 61 + action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 + duration provided by applicable law or treaty (including future time 63 + extensions), (iii) in any current or future medium and for any number of 64 + copies, and (iv) for any purpose whatsoever, including without limitation 65 + commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 + the Waiver for the benefit of each member of the public at large and to the 67 + detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 + shall not be subject to revocation, rescission, cancellation, termination, or 69 + any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 + by the public as contemplated by Affirmer's express Statement of Purpose. 71 + 72 + 3. Public License Fallback. Should any part of the Waiver for any reason be 73 + judged legally invalid or ineffective under applicable law, then the Waiver 74 + shall be preserved to the maximum extent permitted taking into account 75 + Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 + is so judged Affirmer hereby grants to each affected person a royalty-free, 77 + non transferable, non sublicensable, non exclusive, irrevocable and 78 + unconditional license to exercise Affirmer's Copyright and Related Rights in 79 + the Work (i) in all territories worldwide, (ii) for the maximum duration 80 + provided by applicable law or treaty (including future time extensions), (iii) 81 + in any current or future medium and for any number of copies, and (iv) for any 82 + purpose whatsoever, including without limitation commercial, advertising or 83 + promotional purposes (the "License"). The License shall be deemed effective as 84 + of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 + License for any reason be judged legally invalid or ineffective under 86 + applicable law, such partial invalidity or ineffectiveness shall not 87 + invalidate the remainder of the License, and in such case Affirmer hereby 88 + affirms that he or she will not (i) exercise any of his or her remaining 89 + Copyright and Related Rights in the Work or (ii) assert any associated claims 90 + and causes of action with respect to the Work, in either case contrary to 91 + Affirmer's express Statement of Purpose. 92 + 93 + 4. Limitations and Disclaimers. 94 + 95 + a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 + surrendered, licensed or otherwise affected by this document. 97 + 98 + b. Affirmer offers the Work as-is and makes no representations or warranties 99 + of any kind concerning the Work, express, implied, statutory or otherwise, 100 + including without limitation warranties of title, merchantability, fitness 101 + for a particular purpose, non infringement, or the absence of latent or 102 + other defects, accuracy, or the present or absence of errors, whether or not 103 + discoverable, all to the greatest extent permissible under applicable law. 104 + 105 + c. Affirmer disclaims responsibility for clearing rights of other persons 106 + that may apply to the Work or any use thereof, including without limitation 107 + any person's Copyright and Related Rights in the Work. Further, Affirmer 108 + disclaims responsibility for obtaining any necessary consents, permissions 109 + or other rights required for any use of the Work. 110 + 111 + d. Affirmer understands and acknowledges that Creative Commons is not a 112 + party to this document and has no duty or obligation with respect to this 113 + CC0 or use of the Work. 114 + 115 + For more information, please see 116 + <http://creativecommons.org/publicdomain/zero/1.0/>
+131
README.md
··· 1 + # HTTP/Minimal Reference Server 2 + 3 + A reference implementation of an HTTP/Minimal compliant server in Go. 4 + 5 + ## Features 6 + 7 + - Serves Markdown files with proper `text/markdown` Content-Type 8 + - Content negotiation: serves HTML to browsers, Markdown to clients that request it 9 + - Strips raw HTML from Markdown sources (compliance enforcement) 10 + - YAML front matter support for metadata 11 + - `/.well-known/http-minimal` endpoint 12 + - Method restrictions (GET/HEAD only) 13 + - Forbidden header removal 14 + - Designed to run behind nginx/Caddy reverse proxy 15 + 16 + ## Quick Start 17 + 18 + ```bash 19 + # Install dependencies 20 + go mod download 21 + 22 + # Run the server 23 + go run main.go -dir ./content -port 8080 24 + ``` 25 + 26 + ## Usage 27 + 28 + ``` 29 + Usage of http-minimal-server: 30 + -port string 31 + Listen port (default "8080") 32 + -base-url string 33 + Base URL for the site (default "http://localhost:8080") 34 + -contact string 35 + Contact email for /.well-known/http-minimal 36 + -dir string 37 + Content directory (default "./content") 38 + -template string 39 + HTML template file (default: built-in template) 40 + ``` 41 + 42 + ## Content Directory Structure 43 + 44 + ``` 45 + content/ 46 + ├── index.md # Served at / 47 + ├── about.md # Served at /about 48 + ├── posts/ 49 + │ ├── index.md # Served at /posts 50 + │ └── first.md # Served at /posts/first 51 + └── images/ 52 + └── logo.png # Served at /images/logo.png 53 + ``` 54 + 55 + ## Front Matter 56 + 57 + Documents can include YAML front matter: 58 + 59 + ```markdown 60 + --- 61 + title: My Page Title 62 + author: Jane Doe 63 + date: 2025-12-27 64 + lang: en 65 + license: CC0-1.0 66 + --- 67 + 68 + # Content starts here 69 + ``` 70 + 71 + ## Content Negotiation 72 + 73 + The server respects the `Accept` header: 74 + 75 + ```bash 76 + # Get HTML (default for browsers) 77 + curl http://localhost:8080/ 78 + 79 + # Get raw Markdown 80 + curl -H "Accept: text/markdown" http://localhost:8080/ 81 + 82 + # Check compliance policy 83 + curl http://localhost:8080/.well-known/http-minimal 84 + ``` 85 + 86 + ## Compliance 87 + 88 + The server enforces HTTP/Minimal compliance: 89 + 90 + - **Methods**: Only GET and HEAD are allowed 91 + - **Response Headers**: Forbidden headers (Set-Cookie, WWW-Authenticate, etc.) are removed 92 + - **Content**: Raw HTML is stripped from Markdown before serving 93 + - **Validation**: Images without alt text are logged as warnings 94 + 95 + ## Extending 96 + 97 + ### Custom HTML Template 98 + 99 + Modify the `htmlTemplate` constant in `main.go` to customize the HTML rendering. Keep it minimal - no JavaScript, no external resources except same-origin images. 100 + 101 + ### Adding Middleware 102 + 103 + The server implements `http.Handler`, so you can wrap it with standard Go middleware: 104 + 105 + ```go 106 + server, _ := NewServer(config) 107 + handler := loggingMiddleware(server) 108 + http.ListenAndServe(":8080", handler) 109 + ``` 110 + 111 + ### Integration with AT Protocol 112 + 113 + The `/.well-known/http-minimal` endpoint could be extended to include: 114 + 115 + ```json 116 + { 117 + "http_minimal": "0.1", 118 + "compliant": true, 119 + "did": "did:plc:example", 120 + "atproto_pds": "https://bsky.social" 121 + } 122 + ``` 123 + 124 + ## Dependencies 125 + 126 + - [goldmark](https://github.com/yuin/goldmark) - Markdown parser (CommonMark + GFM) 127 + - [yaml.v3](https://gopkg.in/yaml.v3) - YAML front matter parsing 128 + 129 + ## License 130 + 131 + This reference implementation is released under CC0 1.0 Universal - no rights reserved.
+47
content/about.md
··· 1 + --- 2 + title: About HTTP/Minimal 3 + date: 2025-12-27 4 + lang: en 5 + --- 6 + 7 + # About HTTP/Minimal 8 + 9 + HTTP/Minimal is a response to the modern web's complexity and hostility toward users. 10 + 11 + ## The Problem 12 + 13 + The web today requires: 14 + 15 + - Executing arbitrary JavaScript from strangers 16 + - Accepting tracking cookies 17 + - Loading megabytes of frameworks to read a paragraph 18 + - Fighting against dark patterns designed to manipulate you 19 + 20 + ## The Solution 21 + 22 + HTTP/Minimal constrains HTTP to its document-serving roots: 23 + 24 + | Feature | Modern Web | HTTP/Minimal | 25 + |---------|-----------|--------------| 26 + | JavaScript | Required | Forbidden | 27 + | Cookies | Everywhere | Forbidden | 28 + | Tracking | Default | Minimized | 29 + | Content | HTML soup | Clean Markdown | 30 + | Page weight | 2-10 MB | ~10 KB | 31 + 32 + ## Philosophy 33 + 34 + > The web was designed as a document system. HTTP/Minimal returns it to that purpose. 35 + 36 + We believe: 37 + 38 + 1. **Documents should be readable** - both by humans and machines 39 + 2. **Privacy is the default** - not something you opt into 40 + 3. **Simplicity scales** - complex systems fail in complex ways 41 + 4. **Constraints are features** - what you *can't* do matters 42 + 43 + ## Get Involved 44 + 45 + This is an open specification. Fork it, improve it, adopt it. 46 + 47 + The spec is released under [CC0](https://creativecommons.org/publicdomain/zero/1.0/) - no rights reserved.
+51
content/index.md
··· 1 + --- 2 + title: Welcome to HTTP/Minimal 3 + date: 2025-12-27 4 + lang: en 5 + --- 6 + 7 + # Welcome to HTTP/Minimal 8 + 9 + This is a document served over **HTTP/Minimal** - a constrained version of HTTP for human-readable content without tracking, scripts, or manipulation. 10 + 11 + ## What is this? 12 + 13 + HTTP/Minimal is not a new protocol. It's a voluntary restriction on how HTTP is used: 14 + 15 + - HTTPS only, no exceptions 16 + - No cookies or authentication headers 17 + - No JavaScript, ever 18 + - Content is Markdown, not HTML 19 + 20 + ## Why Markdown? 21 + 22 + Markdown is: 23 + 24 + 1. Human-readable as source 25 + 2. Human-writable without tooling 26 + 3. Transparent - tracking pixels are visible in source and enforceable by clients 27 + 4. Universally supported 28 + 29 + ## Try It 30 + 31 + Request this page with different Accept headers: 32 + 33 + ```bash 34 + # Get rendered HTML (default) 35 + curl https://httpmini.com/ 36 + 37 + # Get raw Markdown 38 + curl -H "Accept: text/markdown" https://httpmini.com/ 39 + 40 + # Check compliance 41 + curl https://httpmini.com/.well-known/http-minimal 42 + ``` 43 + 44 + ## Learn More 45 + 46 + - Read the [specification](/spec) 47 + - View the [about page](/about) 48 + 49 + --- 50 + 51 + *[HTTP/Minimal Compliant](/.well-known/http-minimal)*
+537
content/spec.md
··· 1 + # HTTP/Minimal 2 + 3 + **Version:** 0.1.0-draft 4 + **Status:** Proposal 5 + 6 + ## Abstract 7 + 8 + HTTP/Minimal is a constrained version of HTTP designed for serving human-readable documents without tracking, scripting, or behavioral manipulation. It is not a new protocol - it is a voluntary restriction on how HTTP is used, enforceable by clients and verifiable by automated tools. 9 + 10 + ## Goals 11 + 12 + 1. **Radical simplicity.** A document is text and links. Maybe images. 13 + 2. **Privacy by architecture.** No cookies, no auth headers, no state. 14 + 3. **Zero JavaScript.** Not "minimal scripts" - none. 15 + 4. **Works today.** Any static file server can serve compliant content. 16 + 5. **Human-writable source.** Content is authored in Markdown, not markup soup. 17 + 18 + ## Non-Goals 19 + 20 + - Replacing HTTP for applications, APIs, or dynamic content 21 + - Defining a new transport protocol 22 + - Competing with Gemini (this is HTTP; use Gemini if you want Gemini) 23 + 24 + --- 25 + 26 + ## 1. Transport Requirements 27 + 28 + ### 1.1 TLS Required 29 + 30 + All HTTP/Minimal content MUST be served over HTTPS (TLS 1.2+, TLS 1.3 RECOMMENDED). 31 + 32 + Plain HTTP requests SHOULD receive a 301 redirect to the HTTPS equivalent and nothing else. 33 + 34 + ### 1.2 HTTP Version 35 + 36 + HTTP/1.1, HTTP/2, and HTTP/3 are all acceptable. Servers SHOULD support HTTP/2 at minimum. 37 + 38 + --- 39 + 40 + ## 2. Request Constraints 41 + 42 + Compliant clients MUST NOT send the following headers: 43 + 44 + | Header | Reason | 45 + |--------|--------| 46 + | `Cookie` | State tracking | 47 + | `Authorization` | Implies authenticated content | 48 + | `DNT` | Unnecessary - tracking is minimized by design | 49 + | `X-Requested-With` | AJAX patterns not applicable | 50 + | Any `X-` header | Custom headers are a slippery slope | 51 + 52 + Compliant clients MUST use only these methods: 53 + 54 + - `GET` - Retrieve a document 55 + - `HEAD` - Check if a document exists or has changed 56 + 57 + All other methods (`POST`, `PUT`, `DELETE`, etc.) are non-compliant. 58 + 59 + ### 2.1 Query Strings 60 + 61 + Query strings are PERMITTED but SHOULD be limited to: 62 + 63 + - Pagination (`?page=2`) 64 + 65 + Query strings MUST NOT be used for: 66 + 67 + - Session tracking 68 + - User identification 69 + - Analytics parameters (utm_*, fbclid, etc.) 70 + 71 + Compliant servers SHOULD ignore or strip unrecognized query parameters. 72 + 73 + --- 74 + 75 + ## 3. Response Constraints 76 + 77 + ### 3.1 Forbidden Response Headers 78 + 79 + Compliant servers MUST NOT send: 80 + 81 + | Header | Reason | 82 + |--------|--------| 83 + | `Set-Cookie` | State tracking | 84 + | `WWW-Authenticate` | Implies auth-gated content | 85 + | `Content-Security-Policy` | Implies executable content to policy | 86 + | `X-Frame-Options` | Embedding restrictions suggest app behavior | 87 + | `Refresh` | Client-side redirects enable tracking | 88 + 89 + ### 3.2 Permitted Response Headers 90 + 91 + Servers SHOULD send: 92 + 93 + | Header | Purpose | 94 + |--------|---------| 95 + | `Content-Type` | Required (`text/markdown; charset=utf-8`) | 96 + | `Content-Length` | Required for HTTP/1.1 | 97 + | `Last-Modified` | Caching | 98 + | `ETag` | Caching | 99 + | `Cache-Control` | Caching (SHOULD be generous; `max-age=3600` or higher) | 100 + | `Link` | Discovery (see Section 6) | 101 + 102 + ### 3.3 Status Codes 103 + 104 + Compliant servers SHOULD limit responses to: 105 + 106 + | Code | Meaning | 107 + |------|---------| 108 + | `200` | OK | 109 + | `301` | Moved Permanently | 110 + | `304` | Not Modified | 111 + | `400` | Bad Request | 112 + | `404` | Not Found | 113 + | `410` | Gone (content deliberately removed) | 114 + | `500` | Server Error | 115 + 116 + Codes `302`, `303`, and `307` are NOT RECOMMENDED as they enable tracking redirects. 117 + 118 + --- 119 + 120 + ## 4. Content Format 121 + 122 + HTTP/Minimal uses **Markdown** as its content format, served with Content-Type `text/markdown; charset=utf-8`. 123 + 124 + ### 4.1 Markdown Variant 125 + 126 + HTTP/Minimal uses [CommonMark](https://commonmark.org/) as the base specification, with the following extensions PERMITTED: 127 + 128 + - **Tables** - GitHub Flavored Markdown (GFM) pipe tables 129 + - **Strikethrough** - `~~text~~` 130 + - **Autolinks** - GFM automatic URL linking 131 + - **Footnotes** - `[^1]` reference-style footnotes 132 + 133 + ### 4.2 Permitted Syntax 134 + 135 + All standard CommonMark elements: 136 + 137 + - Headings (`#`, `##`, etc.) 138 + - Paragraphs 139 + - Emphasis (`*italic*`, `**bold**`) 140 + - Links (`[text](url)` or `[text][ref]`) 141 + - Images (`![alt](url)`) 142 + - Blockquotes (`>`) 143 + - Code spans (`` `code` ``) 144 + - Code blocks (fenced or indented) 145 + - Lists (ordered and unordered) 146 + - Horizontal rules (`---`) 147 + - Hard line breaks 148 + 149 + ### 4.3 Forbidden Syntax 150 + 151 + The following MUST NOT appear in HTTP/Minimal documents: 152 + 153 + | Syntax | Reason | 154 + |--------|--------| 155 + | Raw HTML blocks | Enables script injection, tracking pixels, forms | 156 + | Raw HTML inline | Same | 157 + | `<script>` | No JavaScript | 158 + | `<iframe>` | Embedded third-party content | 159 + | `<form>` | Data collection | 160 + | `<img>` with tracking URLs | Use Markdown image syntax with compliant URLs | 161 + 162 + Clients MUST strip or ignore any raw HTML encountered in Markdown source. 163 + 164 + ### 4.4 Image Requirements 165 + 166 + Images referenced via `![alt](url)` syntax: 167 + 168 + - MUST include alt text (the `[alt]` portion MUST NOT be empty) 169 + - SHOULD be same-origin or from trusted, non-tracking sources 170 + - MUST NOT be 1x1 tracking pixels 171 + 172 + ### 4.5 Link Requirements 173 + 174 + Links are unrestricted in destination -HTTP/Minimal documents MAY link to any URL. 175 + 176 + Clients MAY warn users when following links to non-compliant origins. 177 + 178 + #### 4.5.1 URL Fragments 179 + 180 + URL fragments (`#section-name`) are supported and SHOULD work as follows: 181 + 182 + - When rendering Markdown to HTML, headings SHOULD receive auto-generated `id` attributes based on their text (lowercase, spaces replaced with hyphens) 183 + - Clients receiving `text/markdown` SHOULD scroll to the heading matching the fragment 184 + - Fragment matching SHOULD be case-insensitive 185 + 186 + For example, `## My Section` would be reachable via `#my-section`. 187 + 188 + ### 4.6 Metadata 189 + 190 + Document metadata SHOULD be provided via a YAML front matter block: 191 + 192 + ```markdown 193 + --- 194 + title: My Document 195 + author: Jane Doe 196 + date: 2025-12-27 197 + lang: en 198 + --- 199 + 200 + # My Document 201 + 202 + Content begins here. 203 + ``` 204 + 205 + Recognized front matter fields: 206 + 207 + | Field | Required | Description | 208 + |-------|----------|-------------| 209 + | `title` | RECOMMENDED | Document title | 210 + | `author` | No | Author name or identifier | 211 + | `date` | No | Publication date (ISO 8601) | 212 + | `lang` | No | Language code (BCP 47) | 213 + | `license` | No | Content license (SPDX identifier or URL) | 214 + 215 + Clients SHOULD use `title` for window/tab titles and `lang` for text rendering. 216 + 217 + --- 218 + 219 + ## 5. Client Rendering 220 + 221 + ### 5.1 Dedicated Clients 222 + 223 + HTTP/Minimal clients SHOULD: 224 + 225 + 1. Parse Markdown and render to styled, readable output 226 + 2. Apply a legible default stylesheet (user-configurable) 227 + 3. Strip any raw HTML before rendering 228 + 4. Display images inline with alt text fallback 229 + 5. Make links clearly navigable 230 + 231 + ### 5.2 Browser Rendering 232 + 233 + Standard browsers receiving `text/markdown` will typically display raw source. Options for browser compatibility: 234 + 235 + **Option A: Server-side rendering** 236 + 237 + Servers MAY content-negotiate and serve pre-rendered HTML to browsers: 238 + 239 + - Request with `Accept: text/markdown` → serve Markdown 240 + - Request with `Accept: text/html` → serve HTML rendering 241 + 242 + The HTML rendering MUST be a direct transformation of the Markdown source with no additions (no scripts, no tracking, no analytics). 243 + 244 + **Option B: Client-side rendering via browser extension** 245 + 246 + Browser extensions may render `text/markdown` responses directly. 247 + 248 + **Option C: Raw display** 249 + 250 + Markdown is human-readable as source. No rendering required. 251 + 252 + ### 5.3 Suggested Default Styles 253 + 254 + Clients SHOULD apply sensible typographic defaults: 255 + 256 + - Readable font (system serif or sans-serif) 257 + - Comfortable line length (45-75 characters) 258 + - Adequate line height (1.4-1.6) 259 + - Responsive viewport handling 260 + - Respect user preferences (dark mode, font size) 261 + 262 + --- 263 + 264 + ## 6. Discovery and Verification 265 + 266 + ### 6.1 Well-Known Endpoint 267 + 268 + Compliant servers SHOULD serve a policy document at: 269 + 270 + ``` 271 + /.well-known/http-minimal 272 + ``` 273 + 274 + This document is a JSON object: 275 + 276 + ```json 277 + { 278 + "http_minimal": "0.1", 279 + "compliant": true, 280 + "scope": "/", 281 + "contact": "mailto:admin@example.com", 282 + "validator": "https://example.com/validation-report.json" 283 + } 284 + ``` 285 + 286 + | Field | Required | Description | 287 + |-------|----------|-------------| 288 + | `http_minimal` | Yes | Spec version | 289 + | `compliant` | Yes | Self-attestation | 290 + | `scope` | No | Path prefix this policy applies to (default: entire origin) | 291 + | `contact` | No | Maintainer contact | 292 + | `validator` | No | URL to a third-party validation report | 293 + 294 + ### 6.2 Link Header 295 + 296 + Responses MAY include: 297 + 298 + ``` 299 + Link: </.well-known/http-minimal>; rel="profile" 300 + ``` 301 + 302 + ### 6.3 Compliance Badge (Optional) 303 + 304 + Documents MAY include a compliance indicator: 305 + 306 + ```markdown 307 + --- 308 + [HTTP/Minimal Compliant](/.well-known/http-minimal) 309 + ``` 310 + 311 + --- 312 + 313 + ## 7. Example Document 314 + 315 + ```markdown 316 + --- 317 + title: Welcome to HTTP/Minimal 318 + author: Jane Doe 319 + date: 2025-12-27 320 + lang: en 321 + --- 322 + 323 + # Welcome to HTTP/Minimal 324 + 325 + This is a document served over **HTTP/Minimal** - a constrained version of 326 + HTTPS for human-readable content without tracking, scripts, or manipulation. 327 + 328 + ## What is this? 329 + 330 + HTTP/Minimal is not a new protocol. It's a voluntary restriction on how 331 + HTTP is used: 332 + 333 + - HTTPS only, no exceptions 334 + - No cookies or authentication headers 335 + - No JavaScript, ever 336 + - Content is Markdown, not HTML 337 + 338 + ## Why Markdown? 339 + 340 + Markdown is: 341 + 342 + 1. Human-readable as source 343 + 2. Human-writable without tooling 344 + 3. Transparent - tracking pixels are visible in source and enforceable by clients 345 + 4. Universally supported 346 + 347 + ## Learn More 348 + 349 + Read the full [specification](/spec) or view the 350 + [source for this page](/source/index.md). 351 + 352 + --- 353 + 354 + *[HTTP/Minimal Compliant](/.well-known/http-minimal)* 355 + ``` 356 + 357 + --- 358 + 359 + ## 8. Relationship to Other Specifications 360 + 361 + ### Gemini Protocol 362 + 363 + HTTP/Minimal shares philosophical goals with Gemini (simplicity, anti-tracking, anti-JavaScript) but differs in approach: 364 + 365 + | Aspect | Gemini | HTTP/Minimal | 366 + |--------|--------|--------------| 367 + | Transport | New protocol (gemini://) | HTTPS | 368 + | Content format | text/gemini | text/markdown | 369 + | Browser support | Requires dedicated client | Works with extensions or server-side rendering | 370 + | Port | 1965 | 443 | 371 + 372 + ### Gemtext vs Markdown 373 + 374 + Gemini's text/gemini format is simpler than Markdown (one link per line, no inline formatting). HTTP/Minimal accepts the tradeoff of a slightly more complex format for broader tooling support. 375 + 376 + ### Semantic Web / Microformats 377 + 378 + HTTP/Minimal Markdown documents can include semantic meaning via: 379 + 380 + - Structured YAML front matter 381 + - Consistent heading hierarchies 382 + - Machine-parseable date/author metadata 383 + 384 + --- 385 + 386 + ## 9. Security Considerations 387 + 388 + - **No cookies** eliminates CSRF and session hijacking vectors 389 + - **No JavaScript** eliminates XSS entirely 390 + - **No forms** eliminates CSRF for data submission 391 + - **No raw HTML** prevents injection attacks 392 + - **HTTPS required** ensures transport security 393 + 394 + Remaining vectors: 395 + 396 + - **Malicious links** - users may still be linked to harmful external sites 397 + - **Image-based tracking** - external images can still leak requests; clients MAY block third-party images 398 + - **Cache timing** - sophisticated attackers may infer browsing history via cache timing; out of scope 399 + 400 + --- 401 + 402 + ## 10. Future Considerations 403 + 404 + Items explicitly deferred for future versions: 405 + 406 + - **Signed content** - integration with Signed HTTP Exchanges or content-addressed storage 407 + - **Content addressing** - integration with IPFS, AT Protocol, or content-hash URLs 408 + - **Client certificates** - Gemini-style TOFU identity without cookies 409 + - **Extended metadata** - richer front matter schemas (JSON-LD, etc.) 410 + - **Media embedding** - whether/how to handle audio and video 411 + 412 + --- 413 + 414 + ## Appendix A: Validator Pseudocode 415 + 416 + ```python 417 + def validate_response(response): 418 + errors = [] 419 + 420 + # Check forbidden response headers 421 + forbidden = ['set-cookie', 'www-authenticate', 'refresh'] 422 + for header in forbidden: 423 + if header in response.headers: 424 + errors.append(f"Forbidden header: {header}") 425 + 426 + # Check content type 427 + content_type = response.headers.get('content-type', '') 428 + if not content_type.startswith('text/markdown'): 429 + errors.append("Content-Type must be text/markdown") 430 + 431 + # Parse Markdown 432 + doc = parse_markdown(response.body) 433 + 434 + # Check for raw HTML 435 + if contains_raw_html(doc): 436 + errors.append("Raw HTML is forbidden") 437 + 438 + # Check images have alt text 439 + for image in doc.images: 440 + if not image.alt: 441 + errors.append(f"Image missing alt text: {image.url}") 442 + 443 + return errors 444 + ``` 445 + 446 + --- 447 + 448 + ## Appendix B: Content-Type Registration 449 + 450 + This specification uses `text/markdown` as defined in [RFC 7763](https://datatracker.ietf.org/doc/html/rfc7763). 451 + 452 + Recommended Content-Type header: 453 + 454 + ``` 455 + Content-Type: text/markdown; charset=utf-8; variant=CommonMark 456 + ``` 457 + 458 + --- 459 + 460 + ## Appendix C: Migration Checklist 461 + 462 + For existing sites adopting HTTP/Minimal: 463 + 464 + - [ ] Enable HTTPS if not already 465 + - [ ] Convert content to Markdown (or add Markdown alongside HTML) 466 + - [ ] Configure server to serve `.md` files as `text/markdown` 467 + - [ ] Remove all analytics (Google Analytics, Plausible, etc.) 468 + - [ ] Remove comment systems or link to external discussion 469 + - [ ] Ensure all images have alt text 470 + - [ ] Ensure no raw HTML in Markdown source 471 + - [ ] Add `/.well-known/http-minimal` endpoint 472 + - [ ] Run validator against all documents 473 + - [ ] Optional: implement content negotiation for HTML clients 474 + 475 + --- 476 + 477 + ## Appendix D: Server Configuration Examples 478 + 479 + ### Nginx 480 + 481 + ```nginx 482 + location ~ \.md$ { 483 + types { text/markdown md; } 484 + charset utf-8; 485 + add_header Link '</.well-known/http-minimal>; rel="profile"'; 486 + 487 + # Remove forbidden headers (shouldn't exist, but defensive) 488 + proxy_hide_header Set-Cookie; 489 + proxy_hide_header WWW-Authenticate; 490 + } 491 + ``` 492 + 493 + ### Caddy 494 + 495 + ```caddyfile 496 + @markdown path *.md 497 + header @markdown Content-Type "text/markdown; charset=utf-8" 498 + header @markdown Link "</.well-known/http-minimal>; rel=\"profile\"" 499 + header @markdown -Set-Cookie 500 + ``` 501 + 502 + ### Static file server (Go) 503 + 504 + ```go 505 + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 506 + if r.Method != "GET" && r.Method != "HEAD" { 507 + http.Error(w, "Method not allowed", 405) 508 + return 509 + } 510 + 511 + if strings.HasSuffix(r.URL.Path, ".md") { 512 + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") 513 + w.Header().Set("Link", "</.well-known/http-minimal>; rel=\"profile\"") 514 + } 515 + 516 + http.FileServer(http.Dir("./public")).ServeHTTP(w, r) 517 + }) 518 + ``` 519 + 520 + --- 521 + 522 + ## Acknowledgments 523 + 524 + This specification draws inspiration from: 525 + 526 + - **[Gemini Protocol](https://geminiprotocol.net/)** - for proving that radical simplicity has an audience 527 + - **["Gemini is Solutionism at its Worst"](https://xn--gckvb8fzb.com/gemini-is-solutionism-at-its-worst/)** - マリウス's critique arguing Gemini's goals could be achieved via HTTP with constraints, not a new protocol 528 + - **[CommonMark](https://commonmark.org/)** - for standardizing Markdown 529 + - **[The Web We Lost](https://www.anildash.com/2012/12/13/the-web-we-lost/)** - Anil Dash's 2012 essay on web degradation 530 + - **[Motherfucking Website](https://motherfuckingwebsite.com/)** - satirical proof that content needs nothing else 531 + - **[IndieWeb](https://indieweb.org/)** - for keeping personal publishing alive 532 + 533 + --- 534 + 535 + ## License 536 + 537 + This specification is released under [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) - no rights reserved. Use it, fork it, improve it.
+8
go.mod
··· 1 + module github.com/shindakun/httpmini-refserver 2 + 3 + go 1.21 4 + 5 + require ( 6 + github.com/yuin/goldmark v1.7.0 7 + gopkg.in/yaml.v3 v3.0.1 8 + )
+6
go.sum
··· 1 + github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= 2 + github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 3 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 4 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 5 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 6 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+688
main.go
··· 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 + 14 + package main 15 + 16 + import ( 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 39 + type 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 48 + type 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 58 + type 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 66 + type 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 73 + const 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 + 140 + func 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 161 + type TemplateViolation struct { 162 + Rule string 163 + Details string 164 + } 165 + 166 + // validateTemplate checks a template for HTTP/Minimal compliance violations 167 + func 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 262 + func 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 330 + func (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 352 + func (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 368 + func (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.) 438 + func (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 466 + func (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 495 + func (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 509 + func (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 533 + func (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 561 + func (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 621 + func (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 666 + func (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 678 + func (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 + }
+66
templates/default.html
··· 1 + <!DOCTYPE html> 2 + <html lang="{{.Lang}}"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>{{.Title}}</title> 7 + <!-- OpenGraph --> 8 + <meta property="og:title" content="{{.Title}}"> 9 + <meta property="og:type" content="article"> 10 + {{if .URL}}<meta property="og:url" content="{{.URL}}">{{end}} 11 + {{if .Description}}<meta property="og:description" content="{{.Description}}">{{end}} 12 + <meta property="og:locale" content="{{.Lang}}"> 13 + {{if .Author}}<meta name="author" content="{{.Author}}">{{end}} 14 + <style> 15 + :root { 16 + --text: #1a1a1a; 17 + --bg: #fefefe; 18 + --link: #0066cc; 19 + --code-bg: #f4f4f4; 20 + } 21 + @media (prefers-color-scheme: dark) { 22 + :root { 23 + --text: #e0e0e0; 24 + --bg: #1a1a1a; 25 + --link: #6db3f2; 26 + --code-bg: #2d2d2d; 27 + } 28 + } 29 + * { box-sizing: border-box; } 30 + body { 31 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; 32 + font-size: 18px; 33 + line-height: 1.6; 34 + color: var(--text); 35 + background: var(--bg); 36 + max-width: 65ch; 37 + margin: 0 auto; 38 + padding: 2rem 1rem; 39 + } 40 + h1, h2, h3, h4, h5, h6 { line-height: 1.2; margin-top: 1.5em; } 41 + a { color: var(--link); } 42 + pre, code { 43 + font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; 44 + font-size: 0.9em; 45 + background: var(--code-bg); 46 + } 47 + pre { padding: 1rem; overflow-x: auto; } 48 + code { padding: 0.1em 0.3em; border-radius: 3px; } 49 + pre code { padding: 0; background: none; } 50 + blockquote { 51 + border-left: 3px solid var(--link); 52 + margin-left: 0; 53 + padding-left: 1rem; 54 + font-style: italic; 55 + } 56 + img { max-width: 100%; height: auto; } 57 + hr { border: none; border-top: 1px solid var(--text); opacity: 0.2; } 58 + table { border-collapse: collapse; width: 100%; } 59 + th, td { border: 1px solid var(--text); padding: 0.5rem; text-align: left; } 60 + th { opacity: 0.8; } 61 + </style> 62 + </head> 63 + <body> 64 + {{.Content}} 65 + </body> 66 + </html>