diff --git a/example.py b/example.py index 4d89b53..25a4157 100644 --- a/example.py +++ b/example.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from spiderweb.decorators import csrf_exempt from spiderweb.main import SpiderwebRouter from spiderweb.exceptions import ServerError @@ -67,6 +69,21 @@ def form(request): else: return TemplateResponse(request, "form.html") +@app.route("/cookies") +def cookies(request): + print("request.COOKIES: ", request.COOKIES) + resp = HttpResponse(body="COOKIES! NOM NOM NOM") + resp.set_cookie(name='nom', value="everyonelovescookies") + resp.set_cookie(name="nom2", value="seriouslycookies") + resp.set_cookie( + name="nom3", + value="yumyum", + partitioned=True, + expires=datetime.utcnow()+timedelta(seconds=10), + max_age=15 + ) + return resp + if __name__ == "__main__": # can also add routes like this: diff --git a/pyproject.toml b/pyproject.toml index 250b235..11a0fa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "spiderweb" -version = "0.8.0" +version = "0.9.0" description = "A small web framework, just big enough to hold your average spider." authors = ["Joe Kaufeld "] readme = "README.md" diff --git a/spiderweb/constants.py b/spiderweb/constants.py index a2d6a18..1da1d0a 100644 --- a/spiderweb/constants.py +++ b/spiderweb/constants.py @@ -1,3 +1,6 @@ DEFAULT_ALLOWED_METHODS = ["GET"] DEFAULT_ENCODING = "ISO-8859-1" -__version__ = "0.8.0" +__version__ = "0.9.0" + +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie +REGEX_COOKIE_NAME = r'^[a-zA-Z0-9\s\(\)<>@,;:\/\\\[\]\?=\{\}\"\t]*$' diff --git a/spiderweb/main.py b/spiderweb/main.py index 371116f..0cfd83a 100644 --- a/spiderweb/main.py +++ b/spiderweb/main.py @@ -92,7 +92,13 @@ class SpiderwebRouter( def fire_response(self, start_response, request: Request, resp: HttpResponse): try: status = get_http_status_by_code(resp.status_code) + cookies = [] + if "Set-Cookie" in resp.headers: + cookies = resp.headers['Set-Cookie'] + del resp.headers['Set-Cookie'] headers = list(resp.headers.items()) + for c in cookies: + headers.append(("Set-Cookie", c)) start_response(status, headers) diff --git a/spiderweb/request.py b/spiderweb/request.py index 930dea9..723d954 100644 --- a/spiderweb/request.py +++ b/spiderweb/request.py @@ -26,9 +26,11 @@ class Request: self.GET = {} self.POST = {} self.META = {} + self.COOKIES = {} self.populate_headers() self.populate_meta() + self.populate_cookies() content_length = int(self.headers.get("CONTENT_LENGTH") or 0) if content_length: @@ -63,6 +65,10 @@ class Request: for f in fields: self.META[f] = self.environ.get(f) + def populate_cookies(self) -> None: + if cookies := self.environ.get("HTTP_COOKIE"): + self.COOKIES = {l.split("=")[0]: l.split("=")[1] for l in cookies.split("; ")} + def json(self): return json.loads(self.content) diff --git a/spiderweb/response.py b/spiderweb/response.py index 06f6b19..9d1e846 100644 --- a/spiderweb/response.py +++ b/spiderweb/response.py @@ -1,10 +1,12 @@ import datetime import json +import re from typing import Any +import urllib.parse import mimetypes from wsgiref.util import FileWrapper -from spiderweb.constants import DEFAULT_ENCODING +from spiderweb.constants import REGEX_COOKIE_NAME from spiderweb.exceptions import GeneralException from spiderweb.request import Request @@ -36,6 +38,63 @@ class HttpResponse: def __str__(self): return self.body + def set_cookie( + self, + name: str, + value: str, + domain: str=None, + expires: datetime.datetime = None, + http_only: bool=None, + max_age: int=None, + partitioned: bool=None, + path: str=None, + secure: bool=False, + same_site: str=None + ): + if not bool(re.match(REGEX_COOKIE_NAME, name)): + url = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes" + raise GeneralException( + f"Cookie name has illegal characters. See {url} for information on" + f" allowed characters." + ) + additions = {} + booleans = [] + + if domain: + additions["Domain"] = domain + if expires: + additions["Expires"] = expires.strftime("%a, %d %b %Y %H:%M:%S GMT") + if max_age: + additions["Max-Age"] = int(max_age) + if path: + additions["Path"] = path + if same_site: + valid_values = ["strict", "lax", "none"] + if same_site.lower() not in valid_values: + raise GeneralException( + f"Invalid value {same_site} for `same_site` cookie attribute. Valid" + f" options are 'strict', 'lax', or 'none'." + ) + additions["SameSite"] = same_site.title() + + if http_only: + booleans.append("HttpOnly") + if partitioned: + booleans.append("Partitioned") + if secure: + booleans.append("Secure") + + attrs = [f"{k}={v}" for k, v in additions.items()] + attrs += booleans + attrs = [urllib.parse.quote_plus(value)] + attrs + cookie = f"{name}={'; '.join(attrs)}" + + if "Set-Cookie" in self.headers: + self.headers["Set-Cookie"].append(cookie) + else: + self.headers["Set-Cookie"] = [cookie] + + def render(self) -> str: return str(self.body)