diff --git a/unwind/db.py b/unwind/db.py index 258d64f..a024a6d 100644 --- a/unwind/db.py +++ b/unwind/db.py @@ -256,6 +256,12 @@ async def update(item): await shared_connection().execute(query=query, values=values) +async def remove(item): + values = asplain(item, fields_={"id"}) + query = f"DELETE FROM {item._table} WHERE id=:id" + await shared_connection().execute(query=query, values=values) + + async def add_or_update_user(user: User): db_user = await get(User, imdb_id=user.imdb_id) if not db_user: diff --git a/unwind/models.py b/unwind/models.py index 7c2862c..184696b 100644 --- a/unwind/models.py +++ b/unwind/models.py @@ -69,12 +69,15 @@ def optional_fields(o): yield f -def asplain(o) -> dict[str, Any]: +def asplain(o, *, fields_: set = None) -> dict[str, Any]: validate(o) d = {} for f in fields(o): + if fields_ is not None and f.name not in fields_: + continue + target = f.type # XXX this doesn't properly support any kind of nested types if (otype := optional_type(f.type)) is not None: @@ -278,6 +281,7 @@ class User: id: ULID = field(default_factory=ULID) imdb_id: str = None name: str = None # canonical user name + secret: str = None @dataclass diff --git a/unwind/sql/20210801-201151--add-user-secret.sql b/unwind/sql/20210801-201151--add-user-secret.sql new file mode 100644 index 0000000..3294a56 --- /dev/null +++ b/unwind/sql/20210801-201151--add-user-secret.sql @@ -0,0 +1,22 @@ +-- add secret to users + +CREATE TABLE _migrate_users ( + id TEXT PRIMARY KEY NOT NULL, + imdb_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + secret TEXT NOT NULL +);; + +INSERT INTO _migrate_users +SELECT + id, + imdb_id, + name, + '' AS secret +FROM users +WHERE true;; + +DROP TABLE users;; + +ALTER TABLE _migrate_users +RENAME TO users;; diff --git a/unwind/web.py b/unwind/web.py index 08b0ab6..3ba5a8e 100644 --- a/unwind/web.py +++ b/unwind/web.py @@ -27,7 +27,7 @@ from .db import close_connection_pool, find_ratings, open_connection_pool from .middleware.responsetime import ResponseTimeMiddleware from .models import Group, Movie, User, asplain from .types import ULID -from .utils import b64encode, phc_compare, phc_scrypt +from .utils import b64decode, b64encode, phc_compare, phc_scrypt log = logging.getLogger(__name__) @@ -308,7 +308,90 @@ async def list_users(request): @requires(["authenticated", "admin"]) async def add_user(request): - not_implemented() + name, imdb_id = await json_from_body(request, ["name", "imdb_id"]) + + # XXX restrict name + # XXX check if imdb_id is well-formed + + secret = secrets.token_bytes() + + user = User(name=name, imdb_id=imdb_id, secret=phc_scrypt(secret)) + await db.add(user) + + return JSONResponse( + { + "secret": b64encode(secret), + "user": asplain(user), + } + ) + + +@route("/users/{user_id}") +@requires(["authenticated", "admin"]) +async def show_user(request): + + user_id = as_ulid(request.path_params["user_id"]) + + user = await db.get(User, id=str(user_id)) + if not user: + return not_found() + + return JSONResponse(asplain(user)) + + +@route("/users/{user_id}", methods=["DELETE"]) +@requires(["authenticated", "admin"]) +async def remove_user(request): + + user_id = as_ulid(request.path_params["user_id"]) + + user = await db.get(User, id=str(user_id)) + if not user: + return not_found() + + async with db.shared_connection().transaction(): + # XXX remove user refs from groups and ratings + + await db.remove(user) + + return JSONResponse(asplain(user)) + + +@route("/users/{user_id}", methods=["PATCH"]) +@requires(["authenticated"]) +async def modify_user(request): + + user_id = as_ulid(request.path_params["user_id"]) + + user = await db.get(User, id=str(user_id)) + if not user: + return not_found() + + is_admin, is_owner = auth(request, user.secret) + if not (is_admin or is_owner): + return forbidden() + + data = await json_from_body(request) + + if is_admin and "name" in data: + # XXX restrict name + user.name = data["name"] + + if is_admin and "imdb_id" in data: + # XXX check if imdb_id is well-formed + user.imdb_id = data["imdb_id"] + + if "secret" in data: + try: + secret = b64decode(data["secret"]) + except: + raise HTTPException(422, f"Invalid secret.") + + user.secret = phc_scrypt(secret) + + await db.update(user) + + return JSONResponse(asplain(user)) @route("/users/{user_id}/ratings")