~james/core

core/sites/src/lib.rs -rw-r--r-- 10.4 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
mod claim;
mod kvmap;
mod serviceauth;
mod sites;
mod xrpc;

use claim::SitesStore;
use kvmap::DomainMapping;
use worker::*;

fn r2_key(did: &str, rkey: &str, site_path: &str) -> String {
    let base = format!("{}/{}/", did, rkey);
    if site_path.is_empty() || site_path == "/" {
        format!("{}index.html", base)
    } else {
        let trimmed = site_path.trim_start_matches('/');
        if trimmed.is_empty() || trimmed.ends_with('/') {
            format!("{}{}index.html", base, trimmed)
        } else {
            format!("{}{}", base, trimmed)
        }
    }
}

// decodes per segment so an encoded slash can't start a new path
fn site_path(path: &str) -> Option<String> {
    let mut segments = Vec::new();
    for segment in path.split('/') {
        let decoded = percent_encoding::percent_decode_str(segment)
            .decode_utf8()
            .ok()?;
        if decoded == "." || decoded == ".." || decoded.contains(['/', '\\', '\0']) {
            return None;
        }
        segments.push(decoded);
    }
    Some(segments.join("/"))
}

fn is_service_host(audience: &str, host: &str) -> bool {
    audience.strip_prefix("did:web:") == Some(host)
}

fn needs_trailing_slash(path: &str) -> bool {
    if path == "/" || path.ends_with('/') {
        return false;
    }
    let last_segment = path.rsplit('/').next().unwrap_or(path);
    !last_segment.contains('.')
}

fn with_trailing_slash(url: &Url) -> String {
    let mut url = url.clone();
    url.set_path(&format!("{}/", url.path()));
    url.to_string()
}

async fn fetch_from_r2(bucket: &Bucket, key: &str) -> Result<Option<Object>> {
    if let Some(obj) = bucket.get(key).execute().await? {
        return Ok(Some(obj));
    }

    let last_segment = key.rsplit('/').next().unwrap_or(key);
    if !last_segment.contains('.') {
        let index_key = format!("{}/index.html", key.trim_end_matches('/'));
        if let Some(obj) = bucket.get(&index_key).execute().await? {
            return Ok(Some(obj));
        }
    }

    Ok(None)
}

fn response_from_object(obj: Object) -> Result<Response> {
    let content_type = obj
        .http_metadata()
        .content_type
        .unwrap_or_else(|| "application/octet-stream".to_string());

    let body = obj
        .body()
        .ok_or_else(|| Error::RustError("empty R2 body".into()))?;
    let mut resp = Response::from_body(body.response_body()?)?;
    resp.headers_mut().set("Content-Type", &content_type)?;
    resp.headers_mut()
        .set("Cache-Control", "public, max-age=60")?;
    Ok(resp)
}

#[event(fetch)]
async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
    let origin = req.headers().get("Origin")?.filter(|o| !o.is_empty());

    if req.method() == Method::Options {
        return preflight_response(&origin);
    }

    let resp = fetch_inner(req, env, ctx).await?;
    let Some(o) = &origin else {
        return Ok(resp);
    };
    // a response passed through from a fetch has immutable headers
    let headers = resp.headers().clone();
    headers.set("Access-Control-Allow-Origin", o)?;
    headers.set("Vary", "Origin")?;
    Ok(resp.with_headers(headers))
}

fn preflight_response(origin: &Option<String>) -> Result<Response> {
    let mut resp = Response::empty()?.with_status(204);
    if let Some(o) = origin {
        resp.headers_mut().set("Access-Control-Allow-Origin", o)?;
        resp.headers_mut()
            .set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")?;
        resp.headers_mut().set(
            "Access-Control-Allow-Headers",
            "authorization, content-type, accept",
        )?;
        resp.headers_mut().set("Access-Control-Max-Age", "86400")?;
    }
    Ok(resp)
}

const HANDLE_TITLE_TTL: u64 = 300;

// shorter negative cache so fresh signups aren't delayed
const UNKNOWN_HANDLE_TTL: u64 = 60;

async fn handle_title(kv: &KvStore, host: &str) -> Result<Option<String>, String> {
    let key = format!("handle-title:{}", host);
    if let Some(memo) = kv.get(&key).text().await.map_err(|e| e.to_string())? {
        let memo = memo.trim();
        return Ok((!memo.is_empty()).then(|| memo.to_string()));
    }

    let resolved = claim::resolve_handle(host.to_string()).await?;
    kv.put(&key, resolved.as_deref().unwrap_or(""))
        .map_err(|e| e.to_string())?
        .expiration_ttl(if resolved.is_none() {
            UNKNOWN_HANDLE_TTL
        } else {
            HANDLE_TITLE_TTL
        })
        .execute()
        .await
        .map_err(|e| e.to_string())?;
    Ok(resolved)
}

async fn fetch_inner(req: Request, env: Env, ctx: Context) -> Result<Response> {
    let url = req.url()?;
    let path = url.path();

    if path.starts_with("/xrpc/") {
        return xrpc::route(req, env, ctx).await;
    }

    let kv = env.kv("SITES")?;
    let bucket = env.bucket("SITES_BUCKET")?;

    let host = req.headers().get("host")?.unwrap_or_default();
    let host = host.split(':').next().unwrap_or("").to_string();

    if host.is_empty() {
        return Response::error("Bad Request: missing host", 400);
    }
    // the service's own host is its did:web, so no site is served there
    let audience = env
        .var("SITES_AUDIENCE_DID")
        .map(|v| v.to_string())
        .unwrap_or_default();
    if is_service_host(&audience, &host) {
        return Response::error("Not Found", 404);
    }

    if path == "/.well-known/atproto-did" {
        // from the domain claim, never the site
        if req.method() != Method::Get {
            let mut resp = Response::error("Method Not Allowed", 405)?;
            resp.headers_mut().set("Allow", "GET")?;
            return Ok(resp);
        }
        // handles are the pdses' to answer, and a fetch on our own zone skips
        // the worker and lands on the origin in front of them
        if claim::is_handle_domain(&host) {
            return Fetch::Request(req).send().await;
        }
        let store = SitesStore::d1(env.d1("SITES_DB")?);
        return match store.select_claim_by_domain(&host).await {
            Ok(Some(claim)) if claim.deleted.is_none() => {
                let mut resp = Response::ok(claim.did)?;
                resp.headers_mut()
                    .set("Cache-Control", "public, max-age=300")?;
                Ok(resp)
            }
            Ok(_) => Response::error("Not Found", 404),
            Err(e) => {
                console_warn!("atproto-did claim lookup for {} failed: {}", host, e);
                Response::error("Internal Error", 500)
            }
        };
    }

    if needs_trailing_slash(path) {
        let redirect_url = with_trailing_slash(&url);
        return Response::redirect_with_status(redirect_url.parse()?, 308);
    }

    let mapping = match kv.get(&host).text().await? {
        Some(raw) => {
            let trimmed = raw.trim();
            if trimmed.is_empty() || trimmed == "{}" {
                return Response::error("site not found!", 404);
            }
            match serde_json::from_str::<DomainMapping>(&raw) {
                Ok(m) => m,
                Err(_) => return Response::error("Internal Error: bad mapping", 500),
            }
        }
        None => return Response::error("site not found!", 404),
    };

    if claim::is_handle_domain(&host) {
        match handle_title(&kv, &host).await {
            Ok(resolved) if resolved.as_deref() == Some(mapping.did.as_str()) => {}
            Ok(_) => {
                console_warn!(
                    "refusing to serve {}: the zone does not resolve it to its mapping's owner",
                    host
                );
                return Response::error("Not Found", 404);
            }
            Err(e) => {
                console_warn!("handle authority lookup for {} failed: {}", host, e);
                return Response::error("Service Unavailable", 503);
            }
        }
    }

    let Some(path) = site_path(url.path()) else {
        return Response::error("Not Found", 404);
    };
    let path = path.as_str();

    let first_segment = path
        .trim_start_matches('/')
        .split('/')
        .next()
        .unwrap_or("")
        .to_string();

    if !first_segment.is_empty() {
        if let Some(entry) = mapping.repos.get(&first_segment) {
            if !entry.is_index() {
                let site_path = path
                    .trim_start_matches('/')
                    .trim_start_matches(&first_segment)
                    .to_string();

                let key = r2_key(&mapping.did, entry.rkey(&first_segment), &site_path);
                return match fetch_from_r2(&bucket, &key).await? {
                    Some(obj) => response_from_object(obj),
                    None => Response::error("Not Found", 404),
                };
            }
        }
    }

    if let Some((name, entry)) = mapping.index_repo() {
        let key = r2_key(&mapping.did, entry.rkey(name), path);
        return match fetch_from_r2(&bucket, &key).await? {
            Some(obj) => response_from_object(obj),
            None => Response::error("Not Found", 404),
        };
    }

    Response::error("Not Found", 404)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_served_path_is_the_uploaded_file_name() {
        for (requested, key) in [
            ("/sp%20ace.txt", "did/site/sp ace.txt"),
            ("/%C3%BC.txt", "did/site/\u{fc}.txt"),
            ("/a%2520b.txt", "did/site/a%20b.txt"),
            (
                "/docs/%E6%97%A5%E6%9C%AC/",
                "did/site/docs/\u{65e5}\u{672c}/index.html",
            ),
            ("/plain.txt", "did/site/plain.txt"),
        ] {
            let path = site_path(requested).unwrap();
            assert_eq!(r2_key("did", "site", &path), key, "{}", requested);
        }
    }

    #[test]
    fn the_service_host_is_not_a_site() {
        let audience = "did:web:sites.next-sites.tngl.sh";
        assert!(is_service_host(audience, "sites.next-sites.tngl.sh"));
        assert!(!is_service_host(audience, "alpha.next-sites.tngl.sh"));
        assert!(!is_service_host(audience, "next-sites.tngl.sh"));
        assert!(!is_service_host(
            "did:plc:sites",
            "sites.next-sites.tngl.sh"
        ));
    }

    #[test]
    fn a_decoded_path_cannot_leave_its_segment() {
        for requested in [
            "/%2e%2e/index.html",
            "/a/%2E/b",
            "/x%2f..%2findex.html",
            "/x%2Fy",
            "/a%5c..%5cb",
            "/nul%00.txt",
            "/bad%ff.txt",
        ] {
            assert_eq!(site_path(requested), None, "{}", requested);
        }
    }
}