Serve files and sign cookies
std.http.files answers a request
with a file from one directory, and
std.http.cookie can sign a cookie
so the server can tell whether a client changed it.
import std.bytes
import std.fs
import std.http
import std.http.cookie
import std.http.files
import std.io
import std.http.server
import std.net
import std.path
import std.secrets
import std.time
from std.bytes import Bytes
from std.fs import Root
from std.http.server import Exchange
from std.net import TcpStream
from std.sync import CancellationToken
pub enum Route:
Files
Login
Me
pub class Site:
pub public: Root
pub keys: list[Bytes]
pub def handle(self, route: &Route, exchange: &mut Exchange[TcpStream]) -> Result[(), AllocError | std.http.HttpError | std.bytes.BytesError | std.http.files.FilesError | std.http.cookie.CookieError]:
match route:
case Route.Files:
path = exchange.params[0]
std.http.files.serve(exchange, &self.public, &path)?
case Route.Login:
mut head = std.http.response(204, "No Content")?
cookie = std.http.cookie.signed_cookie(&self.keys, "user", "Ada")?
std.http.cookie.add(&mut head, &cookie)?
exchange.respond(head, &std.bytes.empty()?)?
case Route.Me:
cookies = std.http.cookie.cookies(&exchange.request.headers, 32)?
match std.http.cookie.find_signed(&cookies, &self.keys, "user")?:
case Some(name):
exchange.send_text(200, &"hello ".concat(&name)?)?
case Nothing:
exchange.send_text(401, "who are you?")?
Ok(())
type FetchError = AllocError | IoError | std.bytes.BytesError | std.net.NetError | RangeError
def fetch(port: u16, request: &str) -> Result[str, FetchError]:
never = CancellationToken()?
deadline = std.time.deadline_after(&std.time.monotonic_now(), &std.time.seconds(5u32))?
mut stream = std.net.connect(&std.net.loopback_v4(port), &deadline, &never)?
data = std.bytes.from_text(request)?
stream.write_all_before(&data, &deadline, &never)?
mut reply = std.bytes.empty()?
mut buffer = std.bytes.empty()?
buffer.resize(512, 0u8)?
while True:
count = stream.read_before(&mut buffer, 0, 512, &deadline, &never)?
if count == 0:
break
reply.extend(&buffer[:count])?
Ok(reply.to_text()?)
# The value of the field `name` in a response, or "".
def field(reply: &str, name: &str) -> Result[str, AllocError | IoError]:
for line in reply.split("\r\n")?:
if line.startswith(name):
return Ok(line.split(": ")?[1])
Ok("")
def get(path: &str, extra: &str) -> Result[str, AllocError]:
Ok(f"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n{extra}\r\n"?)
def client(port: u16, stop: CancellationToken) -> Result[(), FetchError]:
with stop.cancel_on_exit():
page = fetch(port, &get("/files/index.html", "")?)?
print(page.split("\r\n")?[0])?
print(field(&page, "Content-Type")?)?
print(page.split("\r\n\r\n")?[1].strip()?)?
tag = field(&page, "ETag")?
again = fetch(port, &get("/files/index.html", &f"If-None-Match: {tag}\r\n"?)?)?
print(again.split("\r\n")?[0])?
for path in ["/files/../secret.txt", "/files/%2e%2e/secret.txt", "/files/.env"]?:
print(fetch(port, &get(&path, "")?)?.split("\r\n")?[0])?
login = fetch(port, "POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")?
cookie = field(&login, "Set-Cookie")?.split(";")?[0]
print(cookie.startswith("user=Ada."))?
print(fetch(port, &get("/me", &f"Cookie: {cookie}\r\n"?)?)?.split("\r\n\r\n")?[1])?
forged = cookie.replace("Ada", "Eve")?
print(fetch(port, &get("/me", &f"Cookie: {forged}\r\n"?)?)?.split("\r\n\r\n")?[1])?
Ok(())
type FileSiteError = std.fs.FsError | std.io.StreamError | std.http.server.ServerError | std.secrets.SecretsError | std.net.NetError
def main() -> Result[(), SpawnError[()] | IoError | AllocError | FileSiteError | FetchError]:
parent = std.fs.root(&std.path.from_text(".")?)?
mut directory = std.fs.temporary_dir(&parent, &std.path.from_text("plenty-site-")?, 8)?
for name in ["index.html", ".env"]?:
mut staged = std.fs.temporary_file(directory.root())?
staged.stream.file.write("<h1>Welcome</h1>\n")?
staged.publish(&std.path.from_text(name)?)?
staged.close()?
mut routes = std.http.server.router[Route]()?
routes.add("GET", "/files/{path...}", Route.Files)?
routes.add("POST", "/login", Route.Login)?
routes.add("GET", "/me", Route.Me)?
site = Site(parent.open_dir(directory.name())?, [std.secrets.token_bytes(32)?]?)
listener = std.net.listen(&std.net.loopback_v4(0u16), 16)?
port = listener.local_address()?.port
stop = CancellationToken()?
with spawn(client, port, stop.share()).drop_job()? as task:
std.http.server.serve(listener, &routes, &site, &std.http.server.config(), &stop)?
task.join()?
for name in ["index.html", ".env"]?:
directory.root().remove_file(&std.path.from_text(name)?)?
directory.cleanup()?
Ok(())
HTTP/1.1 200 OK
text/html; charset=utf-8
<h1>Welcome</h1>
HTTP/1.1 304 Not Modified
HTTP/1.1 404 Not Found
HTTP/1.1 404 Not Found
HTTP/1.1 404 Not Found
True
hello Ada
who are you?
serve takes the path the route captured, already percent-decoded, and
opens it one segment at a time below public. A segment that is empty or
starts with . is refused, so .. cannot climb out and hidden files such
as .env stay hidden, and a symbolic link is never followed, so a link
inside the directory cannot lead out of it either. Every refusal is a plain
404, which says nothing about what lies outside.
The type comes from the file’s extension, and the response carries an
ETag and Last-Modified built from the file’s size and modification
time. A browser that sends the tag back in If-None-Match gets 304 Not Modified with no body. The file itself is streamed in 16 KiB pieces, so a
large file does not need a large buffer.
signed_cookie appends a tag to the value: an HMAC of the cookie’s name and
value under the first key, so user=Ada.<tag>. find_signed checks the
tag and gives back Ada; a client that changes the value to Eve cannot
compute a matching tag without the key, so the forged cookie is treated as
missing. The value is still readable by the client: signing stops changes,
not reading.
Keys are at least 32 random bytes, kept secret and stable across restarts;
token_bytes here makes a new one per run, which signs out every client
when the program restarts. To replace a key, put the new one first and keep
the old one after it until old cookies have expired: find_signed accepts
any listed key, and signed_cookie always signs with the first.