Skip to content

Commit a982650

Browse files
authored
Merge pull request #29 from oboard/worktree-fix-static-assets-issue
2 parents 12b7610 + 2d2b54f commit a982650

16 files changed

Lines changed: 824 additions & 49 deletions

examples/static_assets/main.mbt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
async fn main {
33
let app = @mocket.new()
44
app.use_middleware(logger_middleware())
5-
// Register global middleware
6-
app.static_assets("/", @static_file.new("./"))
5+
// Register global middleware.
6+
// The mount is "/", so every URL enters the static middleware; with
7+
// fallthrough enabled, requests that match no asset (like the explicit
8+
// route below) continue to the router instead of getting a 404.
9+
app.static_assets("/", @static_file.new("./", fallthrough=true))
710

811
// Text Response
912
app.get("/", _event => "⚡️ Tadaa!")

moon.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ version = "0.8.0"
55
import {
66
"moonbitlang/async@0.21.0",
77
"moonbitlang/x@0.5.1",
8+
"oboard/mimetype@0.2.0",
89
}
910

1011
readme = "README.md"

pkg.generated.mbti

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,8 @@ pub impl Responder for &ToJson
336336
pub impl Responder for StringView
337337

338338
pub(open) trait ServeStaticProvider {
339-
fn get_meta(Self, StringView) -> StaticAssetMeta?
340-
fn get_contents(Self, StringView) -> &Responder
339+
async fn get_meta(Self, StringView) -> StaticAssetMeta?
340+
async fn get_contents(Self, StringView) -> &Responder
341341
fn get_type(Self, String) -> String?
342342
fn get_encodings(Self) -> Map[String, String]
343343
fn get_index_names(Self) -> Array[String]

static.mbt

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,24 @@ pub fn StaticAssetMeta::new(
2121
}
2222

2323
///|
24+
/// A backend for `Mocket::static_assets`.
25+
///
26+
/// Asset ids handed to the provider are virtual absolute paths rooted at the
27+
/// mount point: they always start with "/" and are already normalized, so
28+
/// "." / ".." segments can never escape the provider's root. Providers should
29+
/// join the id onto their root directly.
2430
pub(open) trait ServeStaticProvider {
25-
// This function should resolve asset meta
26-
fn get_meta(Self, id : StringView) -> StaticAssetMeta?
27-
// This function should resolve asset content
28-
fn get_contents(Self, id : StringView) -> &Responder
31+
// Resolve metadata for a candidate asset id.
32+
//
33+
// Return `None` only when the candidate does not exist (or is not a
34+
// servable asset, e.g. a directory); the middleware then keeps probing the
35+
// remaining candidates. I/O failures other than absence should be raised
36+
// so they surface as server errors instead of a misleading 404.
37+
async fn get_meta(Self, id : StringView) -> StaticAssetMeta?
38+
// Resolve asset content. Called only after `get_meta` returned `Some` for
39+
// the same id; a missing file at this point should still yield a 404
40+
// responder, while other I/O failures should yield a 5xx responder.
41+
async fn get_contents(Self, id : StringView) -> &Responder
2942
// Custom MIME type resolver function
3043
fn get_type(Self, ext : String) -> String?
3144
// Encodings map
@@ -56,14 +69,54 @@ test "normalize_path" {
5669
inspect(@posix.Path::normalize("/foo//bar"), content="/foo/bar")
5770
}
5871

72+
///|
73+
/// Join a candidate suffix (usually an index file name) onto a resolved
74+
/// asset id, inserting exactly one path separator between them.
75+
fn join_asset_id(id : String, suffix : String) -> String {
76+
if suffix == "" {
77+
return id
78+
}
79+
let suffix = if suffix.has_prefix("/") { suffix[1:] } else { suffix.view() }
80+
if id.has_suffix("/") {
81+
"\{id}\{suffix}"
82+
} else {
83+
"\{id}/\{suffix}"
84+
}
85+
}
86+
87+
///|
88+
test "join_asset_id" {
89+
inspect(join_asset_id("/", "index.html"), content="/index.html")
90+
inspect(join_asset_id("/app.txt", ""), content="/app.txt")
91+
inspect(join_asset_id("/sub", "index.html"), content="/sub/index.html")
92+
inspect(join_asset_id("/sub/", "/index.html"), content="/sub/index.html")
93+
}
94+
5995
///|
6096
pub fn Mocket::static_assets(
6197
self : Mocket,
6298
path : String,
6399
provider : &ServeStaticProvider,
64100
) -> Unit {
101+
// Normalize the mount point: strip a trailing "/" (except for the root
102+
// mount "/") so matching and slicing have a single canonical form.
103+
let mount = if path.length() > 1 && path.has_suffix("/") {
104+
path[:path.length() - 1].to_owned()
105+
} else {
106+
path
107+
}
65108
self.use_middleware(async fn(event, next) {
66-
if !(match_path(path, event.req.url) is None) {
109+
let url = event.req.url
110+
// Match the mount as a path prefix on a segment boundary, before any
111+
// slicing happens: "/assets" matches "/assets" and everything under
112+
// "/assets/...", but not "/assetsx" or shorter, unrelated URLs. Those
113+
// fall through to the next middleware or route untouched.
114+
let in_mount = if mount == "/" {
115+
url.has_prefix("/")
116+
} else {
117+
url == mount || url.has_prefix("\{mount}/")
118+
}
119+
if !in_mount {
67120
return next()
68121
}
69122

@@ -76,8 +129,15 @@ pub fn Mocket::static_assets(
76129
return HttpResponse::new(MethodNotAllowed)
77130
}
78131

79-
let raw_id = event.req.url[path.length():]
80-
let original_id = Show::to_string(@posix.Path::normalize(raw_id.to_owned()))
132+
// Safe to slice now: `url` equals the mount or starts with "mount/".
133+
let raw_id = if mount == "/" { url.view() } else { url[mount.length():] }
134+
// Resolve under a virtual root so ".." segments can never escape it;
135+
// the normalized id is an absolute path confined to the mount root.
136+
let resolved_id = Show::to_string(
137+
@posix.Path::normalize(
138+
(if raw_id == "" { "/".view() } else { raw_id }).to_owned(),
139+
),
140+
)
81141
// Parse Accept-Encoding
82142
// Headers are Map[StringView, StringView]
83143
let accept_encoding = event.req.headers.get("Accept-Encoding").unwrap_or("")
@@ -98,11 +158,15 @@ pub fn Mocket::static_assets(
98158
}
99159

100160
// Search paths
101-
let mut id = original_id
161+
let mut id = resolved_id
102162
let mut meta : StaticAssetMeta? = None
103-
let index_names = provider.get_index_names()
104-
if index_names.length() == 0 {
105-
ignore(index_names.push("/index.html"))
163+
let index_names = {
164+
let names = provider.get_index_names()
165+
if names.is_empty() {
166+
["index.html"]
167+
} else {
168+
names
169+
}
106170
}
107171

108172
// Search logic: suffix -> encoding
@@ -116,7 +180,7 @@ pub fn Mocket::static_assets(
116180
break
117181
}
118182
for encoding in try_encodings {
119-
let try_id = id + suffix + encoding
183+
let try_id = join_asset_id(id, suffix) + encoding
120184
match provider.get_meta(try_id) {
121185
Some(m) => {
122186
meta = Some(m)
@@ -186,7 +250,7 @@ pub fn Mocket::static_assets(
186250
// Content-Length
187251
match meta.size {
188252
Some(size) =>
189-
if size > 0L && !event.res.headers.contains("Content-Length") {
253+
if size >= 0L && !event.res.headers.contains("Content-Length") {
190254
event.res.headers.set("Content-Length", size.to_string())
191255
}
192256
None => ()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {
2+
"moonbitlang/async/fs",
3+
"moonbitlang/async/os_error",
4+
}
5+
6+
// The imports are only used by the native-only implementation file.
7+
8+
warnings = "-29"
9+
10+
options(
11+
targets: { "nativefs_native.mbt": [ "native" ] },
12+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
///|
2+
/// Async filesystem helpers backing `oboard/mocket/static_file` on the
3+
/// native backend. Kept in a separate package so the provider package can
4+
/// also import the synchronous `moonbitlang/x/fs` for the JS backend
5+
/// without a package-alias collision.
6+
7+
///|
8+
/// Metadata of a regular file.
9+
pub(all) struct FileStat {
10+
size : Int64
11+
mtime : Int64 // seconds since the Unix epoch
12+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Native implementation: all I/O goes through `moonbitlang/async/fs`,
2+
// so requests are served without blocking the event loop.
3+
4+
///|
5+
/// True when `err` means "no such file or path component", i.e. the
6+
/// candidate simply does not exist, as opposed to a real I/O failure.
7+
fn is_not_found_error(err : Error) -> Bool {
8+
match err {
9+
@os_error.OSError(_) as e => e.is_ENOENT() || e.is_ENOTDIR()
10+
_ => false
11+
}
12+
}
13+
14+
///|
15+
/// Stat a regular file. Returns `None` when the path is absent or is not a
16+
/// regular file (e.g. a directory); raises on any other I/O failure so the
17+
/// caller can surface it as a server error instead of a misleading 404.
18+
pub async fn stat_regular_file(path : String) -> FileStat? {
19+
let kind = @fs.kind(path) catch {
20+
err => if is_not_found_error(err) { return None } else { raise err }
21+
}
22+
if kind != @fs.FileKind::Regular {
23+
return None
24+
}
25+
let file = @fs.open(path, mode=ReadOnly)
26+
let size = file.size()
27+
let (mtime, _) = file.mtime()
28+
file.close()
29+
Some({ size, mtime })
30+
}
31+
32+
///|
33+
/// Read an entire file. Returns `None` when the file is absent (e.g. it
34+
/// vanished between stat and read); raises on any other I/O failure.
35+
pub async fn read_file_or_none(path : String) -> Bytes? {
36+
Some(@fs.read_file(path).binary()) catch {
37+
err => if is_not_found_error(err) { None } else { raise err }
38+
}
39+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Generated using `moon info`, DON'T EDIT IT
2+
package "oboard/mocket/static_file/internal/nativefs"
3+
4+
// Values
5+
pub async fn read_file_or_none(String) -> Bytes?
6+
7+
pub async fn stat_regular_file(String) -> FileStat?
8+
9+
// Errors
10+
11+
// Types and methods
12+
pub(all) struct FileStat {
13+
size : Int64
14+
mtime : Int64
15+
}
16+
17+
// Type aliases
18+
19+
// Traits

static_file/moon.pkg

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
11
import {
22
"oboard/mocket",
3+
"oboard/mimetype/lib",
34
"moonbitlang/x/fs",
5+
"oboard/mocket/static_file/internal/nativefs",
46
}
57

6-
// Suppress warning 20 from MoonBit's generated native test driver.
8+
import {
9+
"moonbitlang/async",
10+
"moonbitlang/async/http",
11+
"moonbitlang/core/env",
12+
} for "test"
13+
14+
// Suppress warning 20 from MoonBit's generated native test driver, and
15+
// warning 29 for the fs imports that are each used by only one target's
16+
// provider implementation.
717

8-
warnings = "-20"
18+
warnings = "-20-29"
919

1020
supported_targets = "+js+native"
21+
22+
options(
23+
targets: {
24+
"provider_native.mbt": [ "native" ],
25+
"provider_js.mbt": [ "js" ],
26+
"static_file_metadata_test.mbt": [ "native" ],
27+
},
28+
)

static_file/pkg.generated.mbti

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ import {
66
}
77

88
// Values
9-
pub fn new(String) -> StaticFileProvider
9+
pub let default_index_names : Array[String]
10+
11+
pub fn mime_type_of(String) -> String?
12+
13+
pub fn new(String, fallthrough? : Bool, index_names? : Array[String]) -> StaticFileProvider
1014

1115
// Errors
1216

1317
// Types and methods
1418
pub struct StaticFileProvider {
1519
path : String
20+
fallthrough : Bool
21+
index_names : Array[String]
1622
}
1723
pub impl @mocket.ServeStaticProvider for StaticFileProvider
1824

0 commit comments

Comments
 (0)