gfeh-http 0.1.2

The plain-HTTP view of gfeh: per-file public exposure with ranges and conditional requests
Documentation
#![deny(dead_code)]
#![deny(unsafe_code)]
#![warn(missing_docs)]

//! The plain-HTTP view: one file, one link, no client.
//!
//! Every other view in gfeh speaks to software. This one speaks to whoever was sent
//! the URL -- a browser, `curl`, a `<video>` element, something embedded in a mail
//! client -- so its correctness is measured entirely in headers.
//!
//! # What it serves
//!
//! A published file at `/f/{token}`, and nothing else. There is no directory listing,
//! no upload, no path traversal surface, and no way to name an object except by a token
//! somebody deliberately minted for it. That is the whole point of the design: an
//! opaque token is not a path, so a link cannot be edited into a link to something
//! else, and a rename does not break a URL already in circulation.
//!
//! # The headers that matter
//!
//! - **`Last-Modified` is an HTTP-date**, never ISO 8601. Sending the wrong one made an
//!   S3 object visible in a listing and unreadable by the AWS SDK for Go. Both formats
//!   live in [`gfeh_core`] now so there is one implementation to get right.
//! - **`Range` in all three forms**, including `bytes=-100` meaning the *last* hundred
//!   bytes. Reading that as "from byte 100" hands a video player the opening credits
//!   when it asked for the index at the end of the file.
//! - **`416` carries `Content-Range: bytes */len`**, which is how a client that asked
//!   for too much learns how much there is.
//! - **`ETag` and `If-None-Match`**, answered before the object is opened -- the point
//!   of a conditional request is not to move the bytes.
//! - **`If-Modified-Since`**, for the clients that revalidate on a date because that is
//!   all they were given. The entity tag wins when both arrive, as RFC 9110 requires: an
//!   HTTP-date has one-second resolution, so two writes inside one second share a
//!   `Last-Modified` and a client holding the first would be told it is current.
//! - **`Content-Disposition` with a sanitized filename**, because a name is
//!   user-controlled and a header injection here would be one in every response.
//!
//! # A link is not a login
//!
//! Requests run as the `public` principal with [`Perm::READ`](gfeh_core::Perm::READ)
//! and nothing else, so the enforcement layer below still has the final say. A revoked
//! link answers `404` rather than `403`: telling the holder that the token is real but
//! switched off is information they should not have.
//!
//! # Example
//!
//! ```
//! use gfeh_core::{Disposition, NodeKind, NodeRef, ObjectStore, OpCtx};
//! use gfeh_http::{Exposed, HttpView, StaticExposures};
//! use gfeh_store::MemStore;
//! use std::sync::Arc;
//!
//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
//! let store = Arc::new(MemStore::new());
//! let cx = OpCtx::system("example");
//!
//! let mut handle = store
//!     .create(&cx, &store.root(), "report.pdf", NodeKind::File, Disposition::CreateNew)
//!     .await?;
//! handle.write_at(0, bytes::Bytes::from_static(b"%PDF-1.7")).await?;
//! let meta = handle.close().await?;
//!
//! let exposures = StaticExposures::new().with(
//!     "s3cr3t-token",
//!     Exposed {
//!         node: NodeRef::Id(store.partition(), meta.id),
//!         filename: None,
//!         enabled: true,
//!     },
//! );
//!
//! let view = HttpView::builder(store, Arc::new(exposures)).start().await?;
//! let body = reqwest::get(view.url_for("s3cr3t-token")).await?.text().await?;
//! assert_eq!(body, "%PDF-1.7");
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! # }).unwrap();
//! ```

mod exposure;
mod server;

pub mod conformance;

pub use exposure::{Exposed, Exposures, StaticExposures};
pub use server::{HttpBuilder, HttpView};