serve the files a bundle owns
Every figure and gallery shipped so far emitted links a browser could not fetch: a relative src resolves under the page's URL, and nothing answered there. Found by fetching the pages' own links rather than by reading their markup — the evidence runs had been checking that the right src appeared, never that it worked. A directory bundle's files are now served under its URL. The bundle is looked up first and the file is read only from the directory that bundle owns, never from a path assembled out of the request: ADR-0024 requires that no route serve bundle bytes by path alone, since every byte inside a bundle inherits its publish status. When drafts arrive at queue 19 the filter belongs beside that lookup and nowhere else, which is why the ordering is written down in the comment. A single-file bundle owns nothing: its neighbours belong to the section, and its slash-terminated URL has nothing beneath it. An author with assets writes a directory bundle, now stated in content-model.md. A .md inside a bundle directory is never an asset — it is a bundle with its own URL or a fragment that was never addressable, and serving either raw would publish source. http.ServeFileFS handles content type, conditional requests and ranges, none of which is worth reimplementing here.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
// serveAsset answers a file that lives inside a bundle's own directory, reporting whether it handled the
|
||||
// request.
|
||||
//
|
||||
// This is how a relative `src` in a body actually resolves: a page at /art/monsoon/ links `10-first.jpg`,
|
||||
// the browser asks for /art/monsoon/10-first.jpg, and the bytes come from that bundle's directory.
|
||||
//
|
||||
// The bundle is looked up *first*, and the file is only then read from the directory that bundle owns — never
|
||||
// from a path assembled out of the request. That ordering is what ADR-0024 requires: every byte inside a
|
||||
// bundle inherits the bundle's publish status, so no route may serve bundle bytes by path alone. When drafts
|
||||
// arrive (queue 19) the filter belongs here, beside the lookup, and nowhere else.
|
||||
func serveAsset(w http.ResponseWriter, req *http.Request, site *content.Site, siteFS fs.FS, res resolution) bool {
|
||||
if siteFS == nil {
|
||||
return false
|
||||
}
|
||||
dir, file := path.Split(res.key)
|
||||
route := strings.TrimSuffix(dir, "/")
|
||||
switch {
|
||||
case route == "" || file == "":
|
||||
return false
|
||||
case strings.Contains(file, "/") || file == "." || file == "..":
|
||||
return false
|
||||
case strings.HasSuffix(strings.ToLower(file), ".md"):
|
||||
// A .md inside a bundle is content or a fragment, never an asset. Serving it raw would publish the
|
||||
// partials an author never addressed (content-model.md).
|
||||
return false
|
||||
}
|
||||
key, live := site.KeyFor(route)
|
||||
if !live {
|
||||
return false
|
||||
}
|
||||
b, _, found := site.Lookup(key, res.lang)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
assets, hasAssets := b.Assets()
|
||||
if !hasAssets {
|
||||
return false
|
||||
}
|
||||
name := path.Join(assets, file)
|
||||
if info, err := fs.Stat(siteFS, name); err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
// ServeFileFS handles the content type, conditional requests and ranges — an image wants all three, and
|
||||
// none of them is this engine's business to reimplement.
|
||||
http.ServeFileFS(w, req, siteFS, name)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
func assetHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
fsys := fstest.MapFS{
|
||||
// A directory bundle owns the files beside it.
|
||||
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n{{< gallery >}}\n")},
|
||||
// A Bengali variant, so /bn/ is a language prefix at all: the engine treats a leading segment as a
|
||||
// language only when some bundle is written in it (content-model.md).
|
||||
"content/art/monsoon/index.bn.md": {Data: []byte("---\ntitle: বর্ষা\n---\nx\n")},
|
||||
"content/art/monsoon/10-first.jpg": {Data: []byte("\xff\xd8\xff-not-really-a-jpeg")},
|
||||
"content/art/monsoon/_notes.md": {Data: []byte("a fragment\n")},
|
||||
"content/art/monsoon/notes.md": {Data: []byte("---\ntitle: Notes\n---\nx\n")},
|
||||
// A single-file bundle has no directory of its own.
|
||||
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\nx\n")},
|
||||
"content/posts/loose.jpg": {Data: []byte("bytes")},
|
||||
// A series landing page, whose directory also holds child bundles.
|
||||
"content/comics/series/_index.md": {Data: []byte("---\ntitle: Series\n---\nx\n")},
|
||||
"content/comics/series/cover.png": {Data: []byte("png")},
|
||||
"content/comics/series/one.md": {Data: []byte("---\ntitle: One\n---\nx\n")},
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
|
||||
}
|
||||
|
||||
func TestABundlesOwnFilesAreServed(t *testing.T) {
|
||||
h := assetHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/art/monsoon/10-first.jpg", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200 — this is what a relative src in a body resolves to", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
|
||||
t.Errorf("content-type = %q, want image/jpeg from the extension", ct)
|
||||
}
|
||||
// A landing page's directory works the same way.
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/series/cover.png", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("a series landing page owns its files too: got %d", rec.Code)
|
||||
}
|
||||
// Under a language prefix as well: an image is not translated.
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/art/monsoon/10-first.jpg", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("prefixed request for the same file: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownInsideABundleIsNeverAnAsset(t *testing.T) {
|
||||
// Serving these raw would publish fragments an author never addressed, and hand out the source of a
|
||||
// bundle that has its own rendered URL.
|
||||
h := assetHandler(t)
|
||||
for _, path := range []string{"/art/monsoon/_notes.md", "/art/monsoon/notes.md"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASingleFileBundleOwnsNoDirectory(t *testing.T) {
|
||||
// Its neighbours belong to the section, not to it — so nothing is served under its slash-terminated URL.
|
||||
h := assetHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/plain/loose.jpg", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAssetRequestCannotWanderOffItsBundle(t *testing.T) {
|
||||
h := assetHandler(t)
|
||||
for _, path := range []string{
|
||||
"/art/monsoon/../../posts/loose.jpg",
|
||||
"/art/nonexistent/10-first.jpg",
|
||||
"/art/monsoon/missing.jpg",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -18,7 +18,7 @@ import (
|
||||
func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS, settings content.Settings) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
||||
serve(w, req, site, r)
|
||||
serve(w, req, site, r, siteFS)
|
||||
})
|
||||
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
|
||||
// bundle can own them, since a key always sits under a section.
|
||||
@@ -122,7 +122,7 @@ func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
|
||||
}
|
||||
|
||||
// serve resolves one request and writes its bundle.
|
||||
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
|
||||
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, siteFS fs.FS) {
|
||||
res, ok := resolve(req.URL.Path, site)
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
@@ -151,6 +151,10 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
|
||||
http.Redirect(w, req, content.URL(site.RouteOf(canonical), res.lang), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
// A file inside a bundle's directory: how a relative src in a body resolves (ADR-0024).
|
||||
if serveAsset(w, req, site, siteFS, res) {
|
||||
return
|
||||
}
|
||||
if serveListing(w, req, site, r, res) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user