gfeh_http/lib.rs
1#![deny(dead_code)]
2#![deny(unsafe_code)]
3#![warn(missing_docs)]
4
5//! The plain-HTTP view: one file, one link, no client.
6//!
7//! Every other view in gfeh speaks to software. This one speaks to whoever was sent
8//! the URL -- a browser, `curl`, a `<video>` element, something embedded in a mail
9//! client -- so its correctness is measured entirely in headers.
10//!
11//! # What it serves
12//!
13//! A published file at `/f/{token}`, and nothing else. There is no directory listing,
14//! no upload, no path traversal surface, and no way to name an object except by a token
15//! somebody deliberately minted for it. That is the whole point of the design: an
16//! opaque token is not a path, so a link cannot be edited into a link to something
17//! else, and a rename does not break a URL already in circulation.
18//!
19//! # The headers that matter
20//!
21//! - **`Last-Modified` is an HTTP-date**, never ISO 8601. Sending the wrong one made an
22//! S3 object visible in a listing and unreadable by the AWS SDK for Go. Both formats
23//! live in [`gfeh_core`] now so there is one implementation to get right.
24//! - **`Range` in all three forms**, including `bytes=-100` meaning the *last* hundred
25//! bytes. Reading that as "from byte 100" hands a video player the opening credits
26//! when it asked for the index at the end of the file.
27//! - **`416` carries `Content-Range: bytes */len`**, which is how a client that asked
28//! for too much learns how much there is.
29//! - **`ETag` and `If-None-Match`**, answered before the object is opened -- the point
30//! of a conditional request is not to move the bytes.
31//! - **`If-Modified-Since`**, for the clients that revalidate on a date because that is
32//! all they were given. The entity tag wins when both arrive, as RFC 9110 requires: an
33//! HTTP-date has one-second resolution, so two writes inside one second share a
34//! `Last-Modified` and a client holding the first would be told it is current.
35//! - **`Content-Disposition` with a sanitized filename**, because a name is
36//! user-controlled and a header injection here would be one in every response.
37//!
38//! # A link is not a login
39//!
40//! Requests run as the `public` principal with [`Perm::READ`](gfeh_core::Perm::READ)
41//! and nothing else, so the enforcement layer below still has the final say. A revoked
42//! link answers `404` rather than `403`: telling the holder that the token is real but
43//! switched off is information they should not have.
44//!
45//! # Example
46//!
47//! ```
48//! use gfeh_core::{Disposition, NodeKind, NodeRef, ObjectStore, OpCtx};
49//! use gfeh_http::{Exposed, HttpView, StaticExposures};
50//! use gfeh_store::MemStore;
51//! use std::sync::Arc;
52//!
53//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
54//! let store = Arc::new(MemStore::new());
55//! let cx = OpCtx::system("example");
56//!
57//! let mut handle = store
58//! .create(&cx, &store.root(), "report.pdf", NodeKind::File, Disposition::CreateNew)
59//! .await?;
60//! handle.write_at(0, bytes::Bytes::from_static(b"%PDF-1.7")).await?;
61//! let meta = handle.close().await?;
62//!
63//! let exposures = StaticExposures::new().with(
64//! "s3cr3t-token",
65//! Exposed {
66//! node: NodeRef::Id(store.partition(), meta.id),
67//! filename: None,
68//! enabled: true,
69//! },
70//! );
71//!
72//! let view = HttpView::builder(store, Arc::new(exposures)).start().await?;
73//! let body = reqwest::get(view.url_for("s3cr3t-token")).await?.text().await?;
74//! assert_eq!(body, "%PDF-1.7");
75//! # Ok::<(), Box<dyn std::error::Error>>(())
76//! # }).unwrap();
77//! ```
78
79mod exposure;
80mod server;
81
82pub mod conformance;
83
84pub use exposure::{Exposed, Exposures, StaticExposures};
85pub use server::{HttpBuilder, HttpView};