diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml index 49f4c3e..c30e4b2 100644 --- a/.github/workflows/auto-approve.yml +++ b/.github/workflows/auto-approve.yml @@ -4,11 +4,11 @@ on: pull_request_target jobs: auto-approve: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: pull-requests: write if: github.actor == 'dependabot[bot]' steps: - uses: hmarr/auto-approve-action@v3 with: - github-token: ${{ secrets.PAT_TOKEN }} \ No newline at end of file + github-token: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 825407d..d721564 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,7 +12,7 @@ on: jobs: Analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: actions: read contents: read @@ -22,11 +22,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - - name: Set up Python 3.13 + - name: Set up Python 3 id: setup-python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.x' - name: Cache Dependencies id: cache-pip @@ -50,4 +50,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: - upload: true \ No newline at end of file + upload: true diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 55ea6c6..78dfab4 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -15,7 +15,7 @@ permissions: jobs: dependabot: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: ${{ github.actor == 'dependabot[bot]'}} steps: - name: Dependabot metadata @@ -23,9 +23,10 @@ jobs: uses: dependabot/fetch-metadata@v2 with: github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Enable auto-merge for Dependabot PRs if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' }} run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9805c59..5ebee47 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,7 +7,7 @@ on: jobs: Build-and-Push: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # We want to filter out dependabot # automated pushes to main @@ -50,4 +50,4 @@ jobs: cache-from: type=registry,ref=ghcr.io/ucmercedacm/kanae-build-cache:server cache-to: type=registry,mode=max,ref=ghcr.io/ucmercedacm/kanae-build-cache:server tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 82e8ecd..5fee4d7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ on: jobs: Analyze: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false @@ -34,4 +34,4 @@ jobs: run: | RAW_PYTHON_VERSION=${{ matrix.version }} PYTHON_VERSION=$(echo $RAW_PYTHON_VERSION | sed 's/\.//') - tox -e py$PYTHON_VERSION \ No newline at end of file + tox -e py$PYTHON_VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee14af1..aa3aa3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: - main jobs: Bundle: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: contains(github.event.head_commit.message, '#major') || contains(github.event.head_commit.message, '#minor') || contains(github.event.head_commit.message, '#patch') steps: - name: Checkout Repository @@ -31,7 +31,7 @@ jobs: Release: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: Bundle if: contains(github.event.head_commit.message, '#major') || contains(github.event.head_commit.message, '#minor') || contains(github.event.head_commit.message, '#patch') steps: @@ -66,4 +66,4 @@ jobs: token: ${{ secrets.PAT_TOKEN }} tag: ${{ steps.tag_version.outputs.new_tag }} name: ${{ steps.tag_version.outputs.new_tag }} - artifacts: "releases/kanae-docker.zip,releases/kanae-docker.tar.gz" \ No newline at end of file + artifacts: "releases/kanae-docker.zip,releases/kanae-docker.tar.gz" diff --git a/LICENSE b/LICENSE index d645695..4306581 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024-2025 ACM @ UC Merced Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,4 +199,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/config-example.yml b/config-example.yml index 2e54ec6..d974714 100644 --- a/config-example.yml +++ b/config-example.yml @@ -56,6 +56,12 @@ auth: # MUST be replaced in production website_domain: "http://localhost:5173" + # Allowed origins for CORS + # Leave this as is unless for production + allowed_origins: + - "http://localhost:5173" + - "http://127.0.0.1:5173" + # The backend SuperToken managed or core instance. # https://try.supertokens.com can be used for demo purposes, but we will be replacing it with a developer version connection_uri: "" diff --git a/requirements-dev.txt b/requirements-dev.txt index 291f313..77f4d96 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,6 @@ # Development libraries lefthook>=1.7.22,<2 -pyright>=1.1.355,<2 +pyright[nodejs]>=1.1.355,<2 ruff>=0.3.4,<1 tox>=4.14.2,<5 diff --git a/server/core.py b/server/core.py index 31f2087..ec7946a 100644 --- a/server/core.py +++ b/server/core.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Generator, Optional, Self, Union, Unpack import asyncpg +import orjson from fastapi import Depends, FastAPI, status from fastapi.exceptions import HTTPException, RequestValidationError from fastapi.openapi.utils import get_openapi @@ -17,7 +18,14 @@ ) from supertokens_python.asyncio import list_users_by_account_info from supertokens_python.auth_utils import LinkingToSessionUserFailedError -from supertokens_python.recipe import dashboard, emailpassword, session, thirdparty +from supertokens_python.exceptions import GeneralError +from supertokens_python.recipe import ( + dashboard, + emailpassword, + session, + thirdparty, + userroles, +) from supertokens_python.recipe.session.interfaces import SessionContainer # isort: off @@ -81,6 +89,23 @@ ] +async def init(conn: asyncpg.Connection): + # Refer to https://github.com/MagicStack/asyncpg/issues/140#issuecomment-301477123 + def _encode_jsonb(value): + return b"\x01" + orjson.dumps(value) + + def _decode_jsonb(value): + return orjson.loads(value[1:].decode("utf-8")) + + await conn.set_type_codec( + "jsonb", + schema="pg_catalog", + encoder=_encode_jsonb, + decoder=_decode_jsonb, + format="binary", + ) + + class Kanae(FastAPI): pool: asyncpg.Pool @@ -153,6 +178,7 @@ def __init__( ) ), dashboard.init(), + userroles.init(), ], mode="asgi", ) @@ -165,6 +191,10 @@ def __init__( RequestValidationError, self.request_validation_error_handler, # type: ignore ) + self.add_exception_handler( + GeneralError, + self.general_error_handler, # type: ignore + ) # SuperTokens recipes overrides @@ -369,21 +399,30 @@ async def request_validation_error_handler( message = RequestValidationErrorMessage( errors=[ RequestValidationErrorDetails( - detail=exception["msg"], context=exception["ctx"]["error"] + detail=exception["msg"], + context=exception["ctx"]["error"] if exception.get("ctx") else None, ) for exception in exc.errors() ] ) - return ORJSONResponse( content=message.model_dump(), status_code=status.HTTP_400_BAD_REQUEST ) + async def general_error_handler( + self, request: RouteRequest, exc: GeneralError + ) -> ORJSONResponse: + return ORJSONResponse( + content={"error": str(exc)}, status_code=status.HTTP_400_BAD_REQUEST + ) + ### Server-related utilities @asynccontextmanager async def lifespan(self, app: Self): - async with asyncpg.create_pool(dsn=self.config["postgres_uri"]) as app.pool: + async with asyncpg.create_pool( + dsn=self.config["postgres_uri"], init=init + ) as app.pool: yield def get_db(self) -> Generator[asyncpg.Pool, None, None]: diff --git a/server/launcher.py b/server/launcher.py index 653bb0f..91895e4 100644 --- a/server/launcher.py +++ b/server/launcher.py @@ -23,7 +23,7 @@ app.include_router(router) app.add_middleware( CORSMiddleware, - allow_origins=[config["auth"]["website_domain"]], + allow_origins=config["auth"]["allowed_origins"], allow_credentials=True, allow_methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], allow_headers=["Content-Type"] + get_all_cors_headers(), diff --git a/server/migrations.py b/server/migrations.py index 33a7581..6ba1445 100644 --- a/server/migrations.py +++ b/server/migrations.py @@ -17,7 +17,7 @@ BE = TypeVar("BE", bound=BaseException) -REVISION_FILE = re.compile(r"(?PV)(?P[0-9]+)__(?P.+).sql") +REVISION_FILE = re.compile(r"(?PV)(?P[\d]+)__(?P.+).sql") POSTGRES_URI = config["postgres_uri"] CREATE_MIGRATIONS_TABLE = """ @@ -169,6 +169,7 @@ async def create_migrations_table() -> None: @click.group(short_help="database migrations util", options_metavar="[options]") def main(): + # We don't have any commands to use as the base group pass diff --git a/server/migrations/V3__projects.sql b/server/migrations/V3__projects.sql new file mode 100644 index 0000000..6163630 --- /dev/null +++ b/server/migrations/V3__projects.sql @@ -0,0 +1,67 @@ +-- Revision Version: V3 +-- Revises: V2 +-- Creation Date: 2024-12-26 09:27:35.701551+00:00 UTC +-- Reason: projects + +CREATE TYPE project_type AS ENUM ( + 'independent', + 'sig_ai', + 'sig_swe', + 'sig_cyber', + 'sig_data', + 'sig_arch', + 'sig_graph' +); + +CREATE TYPE project_role AS ENUM ( + 'unaffiliated', + 'member', + 'former', + 'lead', + 'manager' +); + +ALTER TABLE IF EXISTS members ADD COLUMN IF NOT EXISTS role project_role DEFAULT 'unaffiliated'; + +-- Projects by themselves, are basically the same type of relationship compared to events +-- They are many-to-many +-- Ex. A member can be in multiples projects (e.g. Website, UniFoodi, Fishtank, etc), and a project can have multiple members +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + link TEXT, + type project_type DEFAULT 'independent', + active BOOL DEFAULT TRUE, + founded_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() AT TIME ZONE 'utc') +); + +-- A project also is associated with a set of "tags" +-- Meaning that many projects can have many tags +-- This basically implies that we need bridge tables to overcome the gap. +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT +); + +-- Entirely overkill index for "performance reasons" +-- Realistically, given the scale of the data now, it doesn't matter +CREATE INDEX IF NOT EXISTS tags_title_idx ON tags (title); +CREATE INDEX IF NOT EXISTS tags_title_lower_idx ON tags (LOWER(title)); + +-- Bridge table for Projects <--> Tags +-- Many need to adjust the cascade for deletions later. +CREATE TABLE IF NOT EXISTS project_tags ( + project_id UUID REFERENCES projects (id) ON DELETE CASCADE ON UPDATE NO ACTION, + tag_id INTEGER REFERENCES tags (id) ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY (project_id, tag_id) +); + +-- Bridge table for Projects <--> Members +-- Many need to adjust the cascade for deletions later. +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID REFERENCES projects (id) ON DELETE CASCADE ON UPDATE NO ACTION, + member_id UUID REFERENCES members (id) ON DELETE CASCADE ON UPDATE NO ACTION, + PRIMARY KEY (project_id, member_id) +); \ No newline at end of file diff --git a/server/routes/events.py b/server/routes/events.py index b79626d..1f98c51 100644 --- a/server/routes/events.py +++ b/server/routes/events.py @@ -9,6 +9,7 @@ from utils.errors import NotFoundException, NotFoundMessage from utils.pages import KanaePages, KanaeParams, paginate from utils.request import RouteRequest +from utils.roles import has_any_role from utils.router import KanaeRouter router = KanaeRouter(tags=["Events"]) @@ -100,17 +101,17 @@ class ModifiedEventWithDatetime(ModifiedEvent): end_at: datetime.datetime -# Depends on scopes @router.put( "/events/{id}", responses={200: {"model": EventsWithID}, 404: {"model": NotFoundMessage}}, ) +@has_any_role("admin", "leads") @router.limiter.limit("10/minute") async def edit_event( request: RouteRequest, id: uuid.UUID, req: Union[ModifiedEvent, ModifiedEventWithDatetime], - session: SessionContainer = Depends(verify_session), + session: Annotated[SessionContainer, Depends(verify_session())], ) -> EventsWithID: """Updates the specified event""" query = """ @@ -150,16 +151,16 @@ class DeleteResponse(BaseModel, frozen=True): message: str = "ok" -# Depends on scopes @router.delete( "/events/{id}", responses={200: {"model": DeleteResponse}, 404: {"model": NotFoundMessage}}, ) +@has_any_role("admin", "leads") @router.limiter.limit("10/minute") async def delete_event( request: RouteRequest, id: uuid.UUID, - session: SessionContainer = Depends(verify_session), + session: Annotated[SessionContainer, Depends(verify_session())], ) -> DeleteResponse: """Deletes the specified event""" query = """ @@ -173,13 +174,13 @@ async def delete_event( return DeleteResponse() -# Depends on scopes @router.post("/events/create", responses={200: {"model": EventsWithAllID}}) +@has_any_role("admin", "leads") @router.limiter.limit("15/minute") async def create_events( request: RouteRequest, req: EventsWithCreatorID, - session: SessionContainer = Depends(verify_session), + session: Annotated[SessionContainer, Depends(verify_session())], ) -> EventsWithAllID: """Creates a new event given the provided data""" query = """ @@ -197,5 +198,6 @@ async def create_events( # Depends on auth @router.post("/events/join") async def join_event( - request: RouteRequest, session: SessionContainer = Depends(verify_session) + request: RouteRequest, + session: Annotated[SessionContainer, Depends(verify_session())], ): ... diff --git a/server/routes/projects.py b/server/routes/projects.py new file mode 100644 index 0000000..877603f --- /dev/null +++ b/server/routes/projects.py @@ -0,0 +1,490 @@ +import datetime +import uuid +from typing import Annotated, Literal, Optional + +import asyncpg +from fastapi import Depends, HTTPException, Query, status +from pydantic import BaseModel +from supertokens_python.recipe.session import SessionContainer +from supertokens_python.recipe.session.framework.fastapi import verify_session +from supertokens_python.recipe.userroles import UserRoleClaim +from utils.errors import ( + BadRequestException, + HTTPExceptionMessage, + NotFoundException, + NotFoundMessage, +) +from utils.pages import KanaePages, KanaeParams, paginate +from utils.request import RouteRequest +from utils.responses import DeleteResponse +from utils.roles import has_admin_role, has_any_role +from utils.router import KanaeRouter + +router = KanaeRouter(tags=["Projects"]) + + +class ProjectMember(BaseModel, frozen=True): + id: uuid.UUID + name: str + + +class Projects(BaseModel): + id: uuid.UUID + name: str + description: str + link: str + members: list[ProjectMember] + type: Literal[ + "independent", + "sig_ai", + "sig_swe", + "sig_cyber", + "sig_data", + "sig_arch", + "sig_graph", + ] + tags: Optional[list[str]] + active: bool + founded_at: datetime.datetime + + +class PartialProjects(BaseModel): + id: uuid.UUID + name: str + description: str + link: str + type: Literal[ + "independent", + "sig_ai", + "sig_swe", + "sig_cyber", + "sig_data", + "sig_arch", + "sig_graph", + ] + tags: Optional[list[str]] + active: bool + founded_at: datetime.datetime + + +@router.get("/projects") +async def list_projects( + request: RouteRequest, + name: Annotated[Optional[str], Query(min_length=3)] = None, + since: Optional[datetime.datetime] = None, + until: Optional[datetime.datetime] = None, + active: Optional[bool] = True, + *, + params: Annotated[KanaeParams, Depends()], +) -> KanaePages[Projects]: + """Search and filter a list of projects""" + if since and until: + raise BadRequestException( + "Cannot specify both parameters. Must be only one be specified." + ) + + args = [] + time_constraint = "" + + if name: + if since or until: + if since: + time_constraint = "AND projects.founded_at >= $2" + args.append(since) + elif until: + time_constraint = "AND projects.founded_at <= $2" + args.append(until) + + constraint = f"WHERE projects.name % $1 {time_constraint} GROUP BY projects.id ORDER BY similarity(projects.name, $1) DESC" + args.insert(0, name) + elif active is not None: + constraint = "WHERE projects.active = $1 GROUP BY projects.id" + args.append(active) + else: + if since: + time_constraint = "projects.founded_at >= $1 AND projects.active = $2" + args.extend((since, active)) + elif until: + time_constraint = "projects.founded_at <= $1 AND projects.active = $2" + args.extend((until, active)) + constraint = f"WHERE {time_constraint} GROUP BY projects.id" + + # ruff: noqa: S608 + query = f""" + SELECT + projects.id, projects.name, projects.description, projects.link, + jsonb_agg(jsonb_build_object('id', members.id, 'name', members.name, 'role', members.role)) AS members, + projects.type, projects.active, projects.founded_at + FROM projects + INNER JOIN project_members ON project_members.project_id = projects.id + INNER JOIN members ON project_members.member_id = members.id + {constraint} + """ + + return await paginate(request.app.pool, query, *args, params=params) + + +@router.get( + "/projects/{id}", + responses={200: {"model": Projects}, 404: {"model": NotFoundMessage}}, +) +async def get_project(request: RouteRequest, id: uuid.UUID) -> Projects: + """Retrieve project details via ID""" + query = """ + SELECT + projects.id, projects.name, projects.description, projects.link, + jsonb_agg(jsonb_build_object('id', members.id, 'name', members.name)) AS members, + projects.type, projects.active, projects.founded_at + FROM projects + INNER JOIN project_members ON project_members.project_id = projects.id + INNER JOIN members ON project_members.member_id = members.id + WHERE projects.id = $1 + GROUP BY projects.id; + """ + rows = await request.app.pool.fetchrow(query, id) + if not rows: + raise NotFoundException + return Projects(**dict(rows)) + + +class ModifiedProject(BaseModel): + name: str + description: str + link: str + + +@router.put( + "/projects/{id}", + responses={200: {"model": Projects}, 404: {"model": NotFoundMessage}}, +) +@has_any_role("admin", "leads") +@router.limiter.limit("3/minute") +async def edit_project( + request: RouteRequest, + id: uuid.UUID, + req: ModifiedProject, + session: Annotated[SessionContainer, Depends(verify_session())], +): + """Updates the specified project""" + + query = """ + WITH project_member AS ( + SELECT members.id, members.role + FROM projects + INNER JOIN project_members ON project_members.project_id = projects.id + INNER JOIN members ON project_members.member_id = members.id + WHERE projects.id = $1 + ) + UPDATE projects + SET + name = $3, + description = $4, + link = $5 + WHERE + id = $1 + AND EXISTS (SELECT 1 FROM project_member WHERE project_member.id = $2) + AND EXISTS ( + SELECT 1 + FROM members + WHERE members.id = $2 AND members.project_role = 'lead' + ) + RETURNING *; + """ + + roles = await session.get_claim_value(UserRoleClaim) + + if roles and "admin" in roles: + # Effectively admins can override projects + query = """ + WITH project_member AS ( + SELECT members.id, members.role + FROM projects + INNER JOIN project_members ON project_members.project_id = projects.id + INNER JOIN members ON project_members.member_id = members.id + WHERE projects.id = $1 + ) + UPDATE projects + SET + name = $2, + description = $3, + link = $4 + WHERE + id = $1 + RETURNING *; + """ + + args = (id) if roles and "admin" in roles else (id, session.get_user_id()) + rows = await request.app.pool.fetchrow(query, *args, *req.model_dump().values()) + + if not rows: + raise NotFoundException(detail="Resource cannot be updated") + return Projects(**dict(rows)) + + +@router.delete( + "/projects/{id}", + responses={200: {"model": DeleteResponse}, 404: {"model": NotFoundMessage}}, +) +@has_admin_role() +@router.limiter.limit("3/minute") +async def delete_project( + request: RouteRequest, + id: uuid.UUID, + session: Annotated[SessionContainer, Depends(verify_session())], +): + """Deletes the specified project""" + query = """ + DELETE FROM projects + WHERE id = $1 + """ + status = await request.app.pool.execute(query, id) + if status[-1] == "0": + raise NotFoundException + return DeleteResponse() + + +class CreateProject(BaseModel): + name: str + description: str + link: str + type: Literal[ + "independent", + "sig_ai", + "sig_swe", + "sig_cyber", + "sig_data", + "sig_arch", + "sig_graph", + ] + tags: Optional[list[str]] = None + active: bool + founded_at: datetime.datetime + + +@router.post( + "/projects/create", + responses={200: {"model": PartialProjects}, 422: {"model": HTTPExceptionMessage}}, +) +@has_admin_role() +@router.limiter.limit("5/minute") +async def create_project( + request: RouteRequest, + req: CreateProject, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> PartialProjects: + """Creates a new project given the provided data""" + query = """ + INSERT INTO projects (name, description, link, type, active, founded_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *; + """ + + async with request.app.pool.acquire() as connection: + tr = connection.transaction() + + project_rows = await connection.fetchrow( + query, *req.model_dump(exclude={"tags"}).values() + ) + + if req.tags: + subquery = """ + INSERT INTO project_tags (project_id, tag_id) + VALUES ($1, (SELECT id FROM tags WHERE title = $2)); + """ + + await tr.start() + + try: + await request.app.pool.fetchmany( + subquery, [(project_rows["id"], tags.lower()) for tags in req.tags] + ) + except asyncpg.NotNullViolationError: + await tr.rollback() + + # Remove the newly created entry, somewhat like a rollback + await connection.execute( + "DELETE FROM projects WHERE id = $1;", project_rows["id"] + ) + raise HTTPException( + detail="The tag(s) specified is invalid. Please check the current tags available.", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + else: + await tr.commit() + + return PartialProjects(**dict(project_rows), tags=req.tags) + + +class JoinResponse(BaseModel): + message: str + + +@router.post( + "/projects/{id}/join", + responses={200: {"model": JoinResponse}, 409: {"model": HTTPExceptionMessage}}, +) +@router.limiter.limit("5/minute") +async def join_project( + request: RouteRequest, + id: uuid.UUID, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> JoinResponse: + # The member is authenticated already, aka meaning that there is an existing member in our database + query = """ + WITH insert_project_members AS ( + INSERT INTO project_members (project_id, member_id) + VALUES ($1, $2) + RETURNING member_id + ) + UPDATE members + SET project_role = 'member' + WHERE id = (SELECT member_id FROM insert_project_members); + """ + async with request.app.pool.acquire() as connection: + tr = connection.transaction() + await tr.start() + try: + await connection.execute(query, id, session.get_user_id()) + except asyncpg.UniqueViolationError: + await tr.rollback() + raise HTTPException( + detail="Authenticated member has already joined the requested project", + status_code=status.HTTP_409_CONFLICT, + ) + else: + await tr.commit() + return JoinResponse(message="ok") + + +class BulkJoinMember(BaseModel): + id: uuid.UUID + + +@router.post( + "/projects/{id}/bulk-join", + responses={ + 200: {"model": JoinResponse}, + 409: {"model": HTTPExceptionMessage}, + }, +) +@has_any_role("admin", "leads") +@router.limiter.limit("1/minute") +async def bulk_join_project( + request: RouteRequest, + id: uuid.UUID, + req: list[BulkJoinMember], + session: Annotated[SessionContainer, Depends(verify_session())], +) -> JoinResponse: + if len(req) > 10: + raise BadRequestException("Must be less than 10 members") + + # The member is authenticated already, aka meaning that there is an existing member in our database + query = """ + WITH insert_project_members AS ( + INSERT INTO project_members (project_id, member_id) + VALUES ($1, $2) + RETURNING member_id + ) + UPDATE members + SET project_role = 'member' + WHERE id = (SELECT member_id FROM insert_project_members); + """ + async with request.app.pool.acquire() as connection: + tr = connection.transaction() + await tr.start() + try: + await connection.executemany(query, id, [entry.id for entry in req]) + except asyncpg.UniqueViolationError: + await tr.rollback() + raise HTTPException( + detail="Authenticated member has already joined the requested project", + status_code=status.HTTP_409_CONFLICT, + ) + else: + await tr.commit() + return JoinResponse(message="ok") + + +@router.delete( + "/projects/{id}/leave", + responses={200: {"model": DeleteResponse}, 404: {"model": NotFoundMessage}}, +) +@router.limiter.limit("5/minute") +async def leave_project( + request: RouteRequest, + id: uuid.UUID, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> DeleteResponse: + query = """ + DELETE FROM project_members + WHERE project_id = $1 AND member_id = $2; + """ + async with request.app.pool.acquire() as connection: + status = await connection.execute(query, id, session.get_user_id()) + if status[-1] == "0": + raise NotFoundException + + update_role_query = """ + UPDATE members + SET project_role = 'unaffiliated' + WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM project_members WHERE member_id = $1); + """ + await connection.execute(update_role_query, session.get_user_id()) + return DeleteResponse() + + +class UpgradeMemberRole(BaseModel): + id: uuid.UUID + role: Literal["former", "lead"] + + +@router.put( + "/projects/{id}/member/modify", + include_in_schema=False, + responses={200: {"model": DeleteResponse}}, +) +@has_admin_role() +@router.limiter.limit("3/minute") +async def modify_member( + request: RouteRequest, + id: uuid.UUID, + req: UpgradeMemberRole, + session: Annotated[SessionContainer, Depends(verify_session())], +): + """Undocumented route to just upgrade/demote member role in projects""" + query = """ + WITH upgrade_member AS ( + SELECT members.id + FROM projects + INNER JOIN project_members ON project_members.project_id = projects.id + INNER JOIN members ON project_members.member_id = members.id + WHERE projects.id = $1 + ) + UPDATE members + SET project_role = $3 + WHERE members.id = $2 AND EXISTS (SELECT 1 FROM upgrade_member WHERE upgrade_member.id = $2); + """ + await request.app.pool.execute(query, id, req.id, req.role) + return DeleteResponse() + + +@router.get("/projects/me", responses={200: {"model": PartialProjects}}) +@router.limiter.limit("15/minute") +async def get_my_projects( + request: RouteRequest, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> list[PartialProjects]: + """Get all projects associated with the authenticated user""" + query = """ + SELECT + projects.id, projects.name, projects.description, projects.link, + projects.type, projects.active, projects.founded_at + FROM projects + INNER JOIN project_members ON project_members.project_id = projects.id + INNER JOIN members ON project_members.member_id = members.id + WHERE members.id = $1 + GROUP BY projects.id; + """ + + records = await request.app.pool.fetch(query, session.get_user_id()) + return [PartialProjects(**dict(row)) for row in records] diff --git a/server/routes/tags.py b/server/routes/tags.py new file mode 100644 index 0000000..606454c --- /dev/null +++ b/server/routes/tags.py @@ -0,0 +1,158 @@ +from typing import Annotated, Optional + +from fastapi import Depends, Query +from pydantic import BaseModel +from supertokens_python.recipe.session import SessionContainer +from supertokens_python.recipe.session.framework.fastapi import verify_session +from utils.errors import ( + NotFoundException, + NotFoundMessage, +) +from utils.request import RouteRequest +from utils.responses import DeleteResponse +from utils.roles import has_admin_role +from utils.router import KanaeRouter + +router = KanaeRouter(tags=["Tags"]) + + +class Tags(BaseModel): + id: int + title: str + description: str + + +@router.get("/tags") +async def get_tags( + request: RouteRequest, + title: Annotated[Optional[str], Query(min_length=3)] = None, +) -> list[Tags]: + """Get all tags that can be used or sort for a list of tags""" + query = """ + SELECT id, title, description + FROM tags + ORDER BY title DESC + """ + + if title: + query = """ + SELECT id, title, description + FROM tags + WHERE title % $1 + ORDER BY similarity(title, $1) DESC + """ + + args = (title) if title else () + records = await request.app.pool.fetch(query, *args) + return [Tags(**dict(row)) for row in records] + + +@router.get( + "/tags/{id}", + responses={200: {"model": Tags}, 404: {"model": NotFoundMessage}}, +) +async def get_tag_by_id(request: RouteRequest, id: int) -> Tags: + """Get tag via ID""" + query = """ + SELECT id, title, description + FROM tags + WHERE id = $1; + """ + + rows = await request.app.pool.fetchrow(query, id) + if not rows: + raise NotFoundException + return Tags(**dict(rows)) + + +class ModifiedTag(BaseModel): + title: str + description: str + + +@router.put( + "/tags/{id}", + responses={200: {"model": Tags}, 404: {"model": NotFoundMessage}}, +) +@has_admin_role() +@router.limiter.limit("5/minute") +async def edit_tag( + request: RouteRequest, + id: int, + req: ModifiedTag, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> Tags: + """Modify specified tag""" + query = """ + UPDATE tags + SET + title = $2, + description = $3 + WHERE id = $1 + RETURNING *; + """ + rows = await request.app.pool.fetchrow(query, id, *req.model_dump().values()) + if not rows: + raise NotFoundException(detail="Resource cannot be updated") + return Tags(**dict(rows)) + + +@router.delete( + "/tags/{id}", + responses={200: {"model": DeleteResponse}, 404: {"model": NotFoundMessage}}, +) +@has_admin_role() +@router.limiter.limit("5/minute") +async def delete_tag( + request: RouteRequest, + id: int, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> DeleteResponse: + """Remove specified tag""" + query = """ + DELETE FROM tags + WHERE id = $1; + """ + + query_status = await request.app.pool.execute(query, id) + if query_status[-1] == "0": + raise NotFoundException + return DeleteResponse() + + +@router.post("/tags/create", responses={200: {"model": Tags}}) +@has_admin_role() +@router.limiter.limit("5/minute") +async def create_tags( + request: RouteRequest, + req: ModifiedTag, + session: Annotated[SessionContainer, Depends(verify_session())], +) -> Tags: + """Create tag""" + query = """ + INSERT INTO tags (title, description) + VALUES ($1, $2) + RETURNING *; + """ + rows = await request.app.pool.fetchrow(query, *req.model_dump().values()) + return Tags(**dict(rows)) + + +@router.post("/tags/bulk-create", responses={200: {"model": list[Tags]}}) +@has_admin_role() +@router.limiter.limit("1/minute") +async def bulk_create_tags( + request: RouteRequest, + req: list[ModifiedTag], + session: Annotated[SessionContainer, Depends(verify_session())], +) -> list[Tags]: + """Bulk-create tags""" + query = """ + INSERT INTO tags (title, description) + VALUES ($1, $2) + RETURNING *; + """ + records = await request.app.pool.fetchmany( + query, [(tag.title, tag.description) for tag in req] + ) + return [Tags(**dict(tag)) for tag in records] diff --git a/server/routes/user.py b/server/routes/user.py index 9ffd7aa..2e98e1d 100644 --- a/server/routes/user.py +++ b/server/routes/user.py @@ -20,7 +20,6 @@ class GetUser(BaseModel): responses={200: {"model": GetUser}, 404: {"model": NotFound}}, name="Get users", ) -@router.limiter.limit("1/minute") async def get_users(request: RouteRequest) -> GetUser: query = "SELECT 1;" status = await request.app.pool.execute(query) diff --git a/server/utils/errors.py b/server/utils/errors.py index 8d4dcd6..92bf31c 100644 --- a/server/utils/errors.py +++ b/server/utils/errors.py @@ -1,3 +1,5 @@ +from typing import Optional + from fastapi import HTTPException from pydantic import BaseModel @@ -14,9 +16,15 @@ def __init__(self, detail: str = HTTP_404_DETAIL): self.detail = detail +class BadRequestException(HTTPException): + def __init__(self, detail: str): + self.status_code = 400 + self.detail = detail + + class RequestValidationErrorDetails(BaseModel, frozen=True): detail: str - context: str + context: Optional[str] class RequestValidationErrorMessage(BaseModel, frozen=True): diff --git a/server/utils/responses.py b/server/utils/responses.py new file mode 100644 index 0000000..17361d8 --- /dev/null +++ b/server/utils/responses.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class DeleteResponse(BaseModel): + message: str = "ok" diff --git a/server/utils/roles.py b/server/utils/roles.py new file mode 100644 index 0000000..321f2d1 --- /dev/null +++ b/server/utils/roles.py @@ -0,0 +1,101 @@ +# Current scopes: +# read: +# all +# projects +# events +# tags +# write: +# all +# events +# projects +# --------------- +# And current roles: admin, leads +import functools +import inspect +from typing import Any, Callable, Coroutine, Optional, TypeVar + +from supertokens_python.exceptions import GeneralError +from supertokens_python.recipe.session import SessionContainer +from supertokens_python.recipe.session.exceptions import ( + ClaimValidationError, + InvalidClaimsError, +) +from supertokens_python.recipe.userroles import UserRoleClaim + +T = TypeVar("T") + +Coro = Coroutine[Any, Any, T] +CoroFunc = Callable[..., Coro[Any]] + + +def validate_parameters(func: CoroFunc): + sig = inspect.signature(func) + if not sig.parameters.get("session"): + raise GeneralError( + f"No argument found within function <{func.__name__}>" + ) + + +def has_role(item: str, /): + def decorator(func: CoroFunc) -> CoroFunc: + validate_parameters(func) + + @functools.wraps(func) + async def wrapper( + session: Optional[SessionContainer], *args, **kwargs + ) -> CoroFunc: + if not session: + raise GeneralError("Must have valid session") + + roles = await session.get_claim_value(UserRoleClaim) + if not roles or item not in roles: + raise InvalidClaimsError( + f"User does not have role <{item}>", + [ClaimValidationError(UserRoleClaim.key, None)], + ) + + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def has_any_role(*items: str): + def decorator(func: CoroFunc) -> CoroFunc: + validate_parameters(func) + + @functools.wraps(func) + async def wrapper( + session: Optional[SessionContainer], *args, **kwargs + ) -> CoroFunc: + if not session: + raise GeneralError("Must have valid session") + + user_roles = await session.get_claim_value(UserRoleClaim) + + if not user_roles: + raise InvalidClaimsError( + f"User does not any roles listed: {', '.join(role for role in items).rstrip()}", + [ClaimValidationError(UserRoleClaim.key, None)], + ) + if not any(role in user_roles for role in items): + # May need to be tested more + raise InvalidClaimsError( + f"Missing Roles: {', '.join(role for role in items if role not in user_roles).rstrip()}", + [ClaimValidationError(UserRoleClaim.key, None)], + ) + + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def has_admin_role(): + return has_role("admin") + + +def has_leads_role(): + return has_role("leads") diff --git a/server/utils/router.py b/server/utils/router.py index 4273c32..049c64a 100644 --- a/server/utils/router.py +++ b/server/utils/router.py @@ -17,14 +17,12 @@ class PartialConfig(BaseModel, frozen=True): class KanaeRouter(APIRouter): - limiter: Limiter - def __init__(self, **kwargs): super().__init__(**kwargs) # This isn't my favorite implementation, but will do for now - Noelle self._config = self._load_config() - self.limiter = Limiter( + self.limiter: Limiter = Limiter( key_func=get_remote_address, storage_uri=self._config.redis_uri, default_limits=self._config.ratelimits, # type: ignore diff --git a/tox.ini b/tox.ini index 27870f0..107fdb0 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ no_package=true [testenv:lint] description = run linting workflows deps = - pyright>=1.1.355,<2 + pyright[nodejs]>=1.1.355,<2 ruff>=0.3.4,<1 -r requirements.txt commands = diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3c6ef46 --- /dev/null +++ b/uv.lock @@ -0,0 +1,7 @@ +version = 1 +requires-python = ">=3.9, <4.0" + +[[package]] +name = "kanae" +version = "0.1.0" +source = { virtual = "." }